fullcourtdefense-cli 1.0.2 → 1.1.1
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/README.md +4 -4
- package/dist/api.d.ts +1 -0
- package/dist/commands/credits.js +2 -2
- package/dist/commands/discover.d.ts +11 -0
- package/dist/commands/discover.js +283 -0
- package/dist/commands/hook.d.ts +22 -0
- package/dist/commands/hook.js +301 -0
- package/dist/commands/init.js +14 -14
- package/dist/commands/installCursorHook.d.ts +12 -0
- package/dist/commands/installCursorHook.js +186 -0
- package/dist/commands/local-scan.d.ts +3 -0
- package/dist/commands/local-scan.js +908 -8
- package/dist/commands/scan.d.ts +3 -0
- package/dist/commands/scan.js +5 -2
- package/dist/index.js +80 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ fullcourtdefense init
|
|
|
57
57
|
Create a `.fullcourtdefense.yml` to avoid passing flags every time:
|
|
58
58
|
|
|
59
59
|
```yaml
|
|
60
|
-
apiKey: ${
|
|
60
|
+
apiKey: ${FULLCOURTDEFENSE_API_KEY}
|
|
61
61
|
apiUrl: https://api.fullcourtdefense.ai
|
|
62
62
|
shieldId: sh_your_shield_id
|
|
63
63
|
# shieldKey: shsk_optional_if_locked
|
|
@@ -108,7 +108,7 @@ Local scans run from the customer machine or VPN and only send captured text out
|
|
|
108
108
|
|
|
109
109
|
| Flag | Applies To | Description | Default |
|
|
110
110
|
|---|---|---|---|
|
|
111
|
-
| `--api-key <key>` | hosted scan/credits | Hosted scan API key. Can also use `BOTGUARD_API_KEY`. | config/env |
|
|
111
|
+
| `--api-key <key>` | hosted scan/credits | Hosted scan API key. Can also use `FULLCOURTDEFENSE_API_KEY` or legacy `BOTGUARD_API_KEY`. | config/env |
|
|
112
112
|
| `--shield-id <id>` | local scan | Shield ID from the Shield Integrate tab. Can also use `FULLCOURTDEFENSE_SHIELD_ID`, `FCD_SHIELD_ID`, or `AGENTGUARD_SHIELD_ID`. | config/env/prompt |
|
|
113
113
|
| `--shield-key <key>` | local scan | Optional Shield key for locked Shields. Can also use `FULLCOURTDEFENSE_SHIELD_KEY`, `FCD_SHIELD_KEY`, or `AGENTGUARD_SHIELD_KEY`. | config/env/prompt |
|
|
114
114
|
|
|
@@ -206,7 +206,7 @@ Checked: https://api.fullcourtdefense.ai/api/health/ping
|
|
|
206
206
|
Use hosted scans when the agent endpoint is reachable by FullCourtDefense and you have a CI/CD API key.
|
|
207
207
|
|
|
208
208
|
```powershell
|
|
209
|
-
$env:
|
|
209
|
+
$env:FULLCOURTDEFENSE_API_KEY = "bg_live_..."
|
|
210
210
|
fullcourtdefense scan --endpoint "https://support-bot.example.com/chat" --description "Customer support chatbot" --mode sync --format summary --fail-threshold 80
|
|
211
211
|
```
|
|
212
212
|
|
|
@@ -345,7 +345,7 @@ fullcourtdefense scan --local --type endpoint --endpoint "https://agent.internal
|
|
|
345
345
|
Fail the build if score is below 80:
|
|
346
346
|
|
|
347
347
|
```powershell
|
|
348
|
-
fullcourtdefense scan --api-key "$env:
|
|
348
|
+
fullcourtdefense scan --api-key "$env:FULLCOURTDEFENSE_API_KEY" --endpoint "https://agent.example.com/chat" --description "Production support agent" --mode sync --format summary --fail-threshold 80
|
|
349
349
|
```
|
|
350
350
|
|
|
351
351
|
Local CI against a service started earlier in the job:
|
package/dist/api.d.ts
CHANGED
package/dist/commands/credits.js
CHANGED
|
@@ -4,9 +4,9 @@ exports.creditsCommand = creditsCommand;
|
|
|
4
4
|
const api_1 = require("../api");
|
|
5
5
|
const output_1 = require("../output");
|
|
6
6
|
async function creditsCommand(args, config) {
|
|
7
|
-
const apiKey = args.apiKey || config.apiKey || process.env.BOTGUARD_API_KEY;
|
|
7
|
+
const apiKey = args.apiKey || config.apiKey || process.env.FULLCOURTDEFENSE_API_KEY || process.env.BOTGUARD_API_KEY;
|
|
8
8
|
if (!apiKey) {
|
|
9
|
-
console.error('Error: API key required. Use --api-key,
|
|
9
|
+
console.error('Error: API key required. Use --api-key, FULLCOURTDEFENSE_API_KEY env var, or .fullcourtdefense.yml');
|
|
10
10
|
process.exit(1);
|
|
11
11
|
}
|
|
12
12
|
const api = new api_1.BotGuardApi(apiKey, args.apiUrl || config.apiUrl);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { BotGuardConfig } from '../config';
|
|
2
|
+
export interface DiscoverArgs {
|
|
3
|
+
type?: string;
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
apiUrl?: string;
|
|
6
|
+
json?: string;
|
|
7
|
+
upload?: string;
|
|
8
|
+
connectorName?: string;
|
|
9
|
+
extraPath?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function discoverCommand(args: DiscoverArgs, config: BotGuardConfig): Promise<void>;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.discoverCommand = discoverCommand;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const os = __importStar(require("os"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
|
|
41
|
+
const SECRET_VALUE = /sk-[A-Za-z0-9_-]{12,}|ghp_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,}/;
|
|
42
|
+
const SECRET_KEY = /key|token|secret|password|credential|api[_-]?key/i;
|
|
43
|
+
/** Where MCP client configs live, by client + OS. */
|
|
44
|
+
function candidateConfigPaths(cwd, extra) {
|
|
45
|
+
const home = os.homedir();
|
|
46
|
+
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
47
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
48
|
+
const list = [
|
|
49
|
+
// Cursor
|
|
50
|
+
{ path: path.join(home, '.cursor', 'mcp.json'), source: 'Cursor (global)' },
|
|
51
|
+
{ path: path.join(cwd, '.cursor', 'mcp.json'), source: 'Cursor (project)' },
|
|
52
|
+
// Claude Desktop
|
|
53
|
+
{ path: path.join(appData, 'Claude', 'claude_desktop_config.json'), source: 'Claude Desktop (win)' },
|
|
54
|
+
{ path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), source: 'Claude Desktop (mac)' },
|
|
55
|
+
{ path: path.join(xdg, 'Claude', 'claude_desktop_config.json'), source: 'Claude Desktop (linux)' },
|
|
56
|
+
// VS Code (project + user settings)
|
|
57
|
+
{ path: path.join(cwd, '.vscode', 'mcp.json'), source: 'VS Code (project)' },
|
|
58
|
+
{ path: path.join(appData, 'Code', 'User', 'settings.json'), source: 'VS Code (user, win)' },
|
|
59
|
+
{ path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'), source: 'VS Code (user, mac)' },
|
|
60
|
+
{ path: path.join(xdg, 'Code', 'User', 'settings.json'), source: 'VS Code (user, linux)' },
|
|
61
|
+
// Windsurf
|
|
62
|
+
{ path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), source: 'Windsurf' },
|
|
63
|
+
// Generic / repo-root
|
|
64
|
+
{ path: path.join(cwd, '.mcp.json'), source: 'Repo (.mcp.json)' },
|
|
65
|
+
{ path: path.join(cwd, 'mcp.json'), source: 'Repo (mcp.json)' },
|
|
66
|
+
];
|
|
67
|
+
if (extra)
|
|
68
|
+
list.push({ path: path.resolve(extra), source: 'Custom path' });
|
|
69
|
+
return list;
|
|
70
|
+
}
|
|
71
|
+
/** MCP servers can be declared under several keys across clients. */
|
|
72
|
+
function extractServerMap(parsed) {
|
|
73
|
+
if (!parsed || typeof parsed !== 'object')
|
|
74
|
+
return {};
|
|
75
|
+
if (parsed.mcpServers && typeof parsed.mcpServers === 'object')
|
|
76
|
+
return parsed.mcpServers;
|
|
77
|
+
if (parsed.servers && typeof parsed.servers === 'object')
|
|
78
|
+
return parsed.servers;
|
|
79
|
+
if (parsed.mcp?.servers && typeof parsed.mcp.servers === 'object')
|
|
80
|
+
return parsed.mcp.servers;
|
|
81
|
+
if (parsed['mcp.servers'] && typeof parsed['mcp.servers'] === 'object')
|
|
82
|
+
return parsed['mcp.servers'];
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
const RISK_RULES = [
|
|
86
|
+
{ test: /shell|bash|\bexec\b|command[-_]?runner|terminal|subprocess|run[-_]?admin/i, level: 'critical', tag: 'shell/exec' },
|
|
87
|
+
{ test: /postgres|mysql|mongo|sqlite|\bsql\b|database|\bdb\b|redis|snowflake|bigquery/i, level: 'critical', tag: 'database' },
|
|
88
|
+
{ test: /stripe|payment|refund|payout|invoice|billing/i, level: 'critical', tag: 'payments' },
|
|
89
|
+
{ test: /filesystem|file[-_]?system|\bfs\b|read[-_]?file|write[-_]?file/i, level: 'high', tag: 'filesystem' },
|
|
90
|
+
{ test: /github|gitlab|bitbucket/i, level: 'high', tag: 'source-control' },
|
|
91
|
+
{ test: /aws|gcp|azure|cloud|kubernetes|k8s|terraform/i, level: 'high', tag: 'cloud-infra' },
|
|
92
|
+
{ test: /slack|email|gmail|smtp|sendgrid|twilio|notification/i, level: 'high', tag: 'messaging' },
|
|
93
|
+
{ test: /browser|puppeteer|playwright|fetch|http[-_]?request|web[-_]?search/i, level: 'medium', tag: 'web/network' },
|
|
94
|
+
{ test: /drive|gdrive|dropbox|s3|storage|notion|confluence/i, level: 'medium', tag: 'data-store' },
|
|
95
|
+
];
|
|
96
|
+
function scoreRisk(s) {
|
|
97
|
+
const hay = [s.serverName, s.command || '', ...(s.args || []), s.url || ''].join(' ');
|
|
98
|
+
const tags = [];
|
|
99
|
+
let level = 'low';
|
|
100
|
+
const order = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
101
|
+
for (const rule of RISK_RULES) {
|
|
102
|
+
if (rule.test.test(hay)) {
|
|
103
|
+
tags.push(rule.tag);
|
|
104
|
+
if (order[rule.level] > order[level])
|
|
105
|
+
level = rule.level;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { level, tags };
|
|
109
|
+
}
|
|
110
|
+
function parseConfigFile(filePath, source) {
|
|
111
|
+
let raw;
|
|
112
|
+
try {
|
|
113
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(raw);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return []; // settings.json with comments/JSONC — best-effort skip
|
|
124
|
+
}
|
|
125
|
+
const servers = extractServerMap(parsed);
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const [name, cfgRaw] of Object.entries(servers)) {
|
|
128
|
+
const cfg = (cfgRaw || {});
|
|
129
|
+
const command = typeof cfg.command === 'string' ? cfg.command : undefined;
|
|
130
|
+
const args = Array.isArray(cfg.args) ? cfg.args.map(String) : undefined;
|
|
131
|
+
const url = typeof cfg.url === 'string' ? cfg.url : (typeof cfg.serverUrl === 'string' ? cfg.serverUrl : undefined);
|
|
132
|
+
const env = (cfg.env && typeof cfg.env === 'object') ? cfg.env : {};
|
|
133
|
+
const envKeys = Object.keys(env);
|
|
134
|
+
const transport = url
|
|
135
|
+
? (/\/sse(\b|$)/.test(url) ? 'sse' : 'http')
|
|
136
|
+
: command ? 'stdio' : 'unknown';
|
|
137
|
+
const tools = Array.isArray(cfg.tools)
|
|
138
|
+
? cfg.tools.map((t) => (typeof t === 'string' ? t : t?.name)).filter(Boolean).map(String)
|
|
139
|
+
: [];
|
|
140
|
+
const { level, tags } = scoreRisk({ serverName: name, command, args, url });
|
|
141
|
+
const warnings = [];
|
|
142
|
+
const blob = `${command || ''} ${(args || []).join(' ')} ${JSON.stringify(env)}`;
|
|
143
|
+
if (SECRET_VALUE.test(blob))
|
|
144
|
+
warnings.push('Hardcoded secret/API token detected in config (rotate + use env var).');
|
|
145
|
+
for (const [k, v] of Object.entries(env)) {
|
|
146
|
+
const val = String(v ?? '');
|
|
147
|
+
if (SECRET_KEY.test(k) && val && !/^\$\{?[A-Za-z0-9_]+\}?$/.test(val)) {
|
|
148
|
+
warnings.push(`Env "${k}" appears to contain a literal secret value.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
out.push({
|
|
152
|
+
serverName: name,
|
|
153
|
+
command,
|
|
154
|
+
args,
|
|
155
|
+
url,
|
|
156
|
+
transport,
|
|
157
|
+
envKeys,
|
|
158
|
+
tools,
|
|
159
|
+
source,
|
|
160
|
+
configPath: filePath,
|
|
161
|
+
riskLevel: level,
|
|
162
|
+
riskTags: tags,
|
|
163
|
+
warnings,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/** Merge servers found in multiple files; keep richest record, union sources. */
|
|
169
|
+
function dedupe(servers) {
|
|
170
|
+
const byName = new Map();
|
|
171
|
+
for (const s of servers) {
|
|
172
|
+
const existing = byName.get(s.serverName);
|
|
173
|
+
if (!existing) {
|
|
174
|
+
byName.set(s.serverName, { ...s, source: s.source });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!existing.source.includes(s.source))
|
|
178
|
+
existing.source += `, ${s.source}`;
|
|
179
|
+
existing.command = existing.command || s.command;
|
|
180
|
+
existing.url = existing.url || s.url;
|
|
181
|
+
existing.tools = Array.from(new Set([...existing.tools, ...s.tools]));
|
|
182
|
+
existing.warnings = Array.from(new Set([...existing.warnings, ...s.warnings]));
|
|
183
|
+
}
|
|
184
|
+
return [...byName.values()];
|
|
185
|
+
}
|
|
186
|
+
const COLOR = {
|
|
187
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
188
|
+
red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
189
|
+
};
|
|
190
|
+
function riskColor(level) {
|
|
191
|
+
return level === 'critical' || level === 'high' ? COLOR.red : level === 'medium' ? COLOR.yellow : COLOR.green;
|
|
192
|
+
}
|
|
193
|
+
async function upload(servers, apiUrl, apiKey, connectorName) {
|
|
194
|
+
const payload = {
|
|
195
|
+
connectorName: connectorName || 'Local MCP discovery (CLI)',
|
|
196
|
+
servers: servers.map(s => ({
|
|
197
|
+
serverName: s.serverName,
|
|
198
|
+
command: s.command,
|
|
199
|
+
url: s.url,
|
|
200
|
+
tools: s.tools.map(name => ({ name })),
|
|
201
|
+
})),
|
|
202
|
+
};
|
|
203
|
+
const resp = await fetch(`${apiUrl.replace(/\/$/, '')}/api/cicd/discovery/mcp`, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
|
206
|
+
body: JSON.stringify(payload),
|
|
207
|
+
});
|
|
208
|
+
const data = await resp.json().catch(() => ({}));
|
|
209
|
+
if (!resp.ok || data.success === false) {
|
|
210
|
+
throw new Error(data.error || `Upload failed (${resp.status})`);
|
|
211
|
+
}
|
|
212
|
+
const ingested = data.data?.ingested ?? servers.length;
|
|
213
|
+
console.log(`${COLOR.green}Uploaded ${ingested} MCP server(s) to your AI Inventory.${COLOR.reset}`);
|
|
214
|
+
}
|
|
215
|
+
async function discoverCommand(args, config) {
|
|
216
|
+
const type = (args.type || 'mcp').toLowerCase();
|
|
217
|
+
if (type !== 'mcp') {
|
|
218
|
+
console.error(`Unsupported discover type "${type}". Supported: mcp`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
const cwd = process.cwd();
|
|
222
|
+
const candidates = candidateConfigPaths(cwd, args.extraPath);
|
|
223
|
+
const scanned = [];
|
|
224
|
+
let found = [];
|
|
225
|
+
for (const c of candidates) {
|
|
226
|
+
if (!fs.existsSync(c.path))
|
|
227
|
+
continue;
|
|
228
|
+
scanned.push(`${c.source} → ${c.path}`);
|
|
229
|
+
found.push(...parseConfigFile(c.path, c.source));
|
|
230
|
+
}
|
|
231
|
+
found = dedupe(found);
|
|
232
|
+
if (args.json === 'true') {
|
|
233
|
+
console.log(JSON.stringify({ scannedFiles: scanned, servers: found }, null, 2));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
console.log(`\n${COLOR.bold}${COLOR.cyan}MCP server discovery${COLOR.reset}`);
|
|
237
|
+
console.log(`${COLOR.gray}Scanned ${candidates.length} known config locations on this machine.${COLOR.reset}\n`);
|
|
238
|
+
if (scanned.length === 0) {
|
|
239
|
+
console.log(`${COLOR.yellow}No MCP config files found.${COLOR.reset}`);
|
|
240
|
+
console.log(`${COLOR.gray}Looked in Cursor, Claude Desktop, VS Code, Windsurf, and repo-root configs.${COLOR.reset}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
console.log(`${COLOR.bold}Config files found:${COLOR.reset}`);
|
|
244
|
+
for (const s of scanned)
|
|
245
|
+
console.log(` ${COLOR.dim}•${COLOR.reset} ${s}`);
|
|
246
|
+
console.log('');
|
|
247
|
+
if (found.length === 0) {
|
|
248
|
+
console.log(`${COLOR.yellow}Config files exist but declare no MCP servers.${COLOR.reset}`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const order = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
252
|
+
found.sort((a, b) => order[a.riskLevel] - order[b.riskLevel]);
|
|
253
|
+
console.log(`${COLOR.bold}${found.length} MCP server(s) discovered:${COLOR.reset}\n`);
|
|
254
|
+
for (const s of found) {
|
|
255
|
+
const col = riskColor(s.riskLevel);
|
|
256
|
+
const target = s.command ? `${s.command} ${(s.args || []).join(' ')}`.trim() : (s.url || 'unknown');
|
|
257
|
+
console.log(` ${col}${COLOR.bold}[${s.riskLevel.toUpperCase()}]${COLOR.reset} ${COLOR.bold}${s.serverName}${COLOR.reset} ${COLOR.gray}(${s.transport})${COLOR.reset}`);
|
|
258
|
+
console.log(` ${COLOR.gray}target:${COLOR.reset} ${target}`);
|
|
259
|
+
if (s.riskTags.length)
|
|
260
|
+
console.log(` ${COLOR.gray}tags:${COLOR.reset} ${s.riskTags.join(', ')}`);
|
|
261
|
+
if (s.tools.length)
|
|
262
|
+
console.log(` ${COLOR.gray}tools:${COLOR.reset} ${s.tools.slice(0, 12).join(', ')}${s.tools.length > 12 ? ' …' : ''}`);
|
|
263
|
+
console.log(` ${COLOR.gray}from:${COLOR.reset} ${s.source}`);
|
|
264
|
+
for (const w of s.warnings)
|
|
265
|
+
console.log(` ${COLOR.red}⚠ ${w}${COLOR.reset}`);
|
|
266
|
+
console.log('');
|
|
267
|
+
}
|
|
268
|
+
const counts = found.reduce((acc, s) => { acc[s.riskLevel] = (acc[s.riskLevel] || 0) + 1; return acc; }, {});
|
|
269
|
+
console.log(`${COLOR.bold}Summary:${COLOR.reset} ${counts.critical || 0} critical · ${counts.high || 0} high · ${counts.medium || 0} medium · ${counts.low || 0} low`);
|
|
270
|
+
if (args.upload === 'true') {
|
|
271
|
+
const apiKey = args.apiKey || config.apiKey || process.env.FULLCOURTDEFENSE_API_KEY;
|
|
272
|
+
const apiUrl = args.apiUrl || config.apiUrl || DEFAULT_API_URL;
|
|
273
|
+
if (!apiKey) {
|
|
274
|
+
console.error(`\n${COLOR.red}--upload requires an API key.${COLOR.reset} Set --api-key, FULLCOURTDEFENSE_API_KEY, or apiKey in config.`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
console.log('');
|
|
278
|
+
await upload(found, apiUrl, apiKey, args.connectorName);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
console.log(`${COLOR.gray}Run with --upload to push these into your AI Inventory.${COLOR.reset}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { BotGuardConfig } from '../config';
|
|
2
|
+
/**
|
|
3
|
+
* `fullcourtdefense hook` — Cursor hook bridge.
|
|
4
|
+
*
|
|
5
|
+
* Cursor invokes this command for agent lifecycle events (beforeSubmitPrompt,
|
|
6
|
+
* beforeShellExecution, beforeMCPExecution, afterFileEdit, ...). Cursor pipes a
|
|
7
|
+
* JSON payload on stdin and reads a JSON verdict on stdout. Exit code 2 also
|
|
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).
|
|
10
|
+
*
|
|
11
|
+
* Verdict source of truth: POST {apiUrl}/api/shield/analyze/{shieldId}.
|
|
12
|
+
*/
|
|
13
|
+
export interface HookArgs {
|
|
14
|
+
event?: string;
|
|
15
|
+
apiUrl?: string;
|
|
16
|
+
shieldId?: string;
|
|
17
|
+
shieldKey?: string;
|
|
18
|
+
shadow?: string;
|
|
19
|
+
failClosed?: string;
|
|
20
|
+
timeout?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function hookCommand(args: HookArgs, config: BotGuardConfig): Promise<void>;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.hookCommand = hookCommand;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const os = __importStar(require("os"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
|
|
41
|
+
function readStdin() {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
let data = '';
|
|
44
|
+
const stdin = process.stdin;
|
|
45
|
+
if (stdin.isTTY) {
|
|
46
|
+
resolve('');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
stdin.setEncoding('utf8');
|
|
50
|
+
stdin.on('data', (chunk) => { data += chunk; });
|
|
51
|
+
stdin.on('end', () => resolve(data));
|
|
52
|
+
stdin.on('error', () => resolve(data));
|
|
53
|
+
// Safety: if nothing arrives, don't hang forever.
|
|
54
|
+
setTimeout(() => resolve(data), 2000).unref?.();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Load ~/.fullcourtdefense.yml (machine-wide creds) as a fallback to project config. */
|
|
58
|
+
function loadHomeShield() {
|
|
59
|
+
try {
|
|
60
|
+
const candidates = ['.fullcourtdefense.yml', '.fullcourtdefense.yaml'];
|
|
61
|
+
for (const name of candidates) {
|
|
62
|
+
const p = path.join(os.homedir(), name);
|
|
63
|
+
if (!fs.existsSync(p))
|
|
64
|
+
continue;
|
|
65
|
+
const text = fs.readFileSync(p, 'utf8');
|
|
66
|
+
const pick = (key) => {
|
|
67
|
+
const m = text.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)\\s*$`, 'm'));
|
|
68
|
+
if (!m)
|
|
69
|
+
return undefined;
|
|
70
|
+
return m[1].replace(/^["']|["']$/g, '').trim() || undefined;
|
|
71
|
+
};
|
|
72
|
+
return { shieldId: pick('shieldId'), shieldKey: pick('shieldKey'), apiUrl: pick('apiUrl') };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch { /* ignore */ }
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
function inferEvent(explicit, payload) {
|
|
79
|
+
const e = (explicit || '').toLowerCase();
|
|
80
|
+
if (e) {
|
|
81
|
+
if (e.startsWith('prompt'))
|
|
82
|
+
return 'prompt';
|
|
83
|
+
if (e.startsWith('shell'))
|
|
84
|
+
return 'shell';
|
|
85
|
+
if (e.startsWith('mcp'))
|
|
86
|
+
return 'mcp';
|
|
87
|
+
if (e.startsWith('file') || e === 'edit')
|
|
88
|
+
return 'file';
|
|
89
|
+
if (e.startsWith('read'))
|
|
90
|
+
return 'read';
|
|
91
|
+
}
|
|
92
|
+
if (typeof payload.command === 'string')
|
|
93
|
+
return 'shell';
|
|
94
|
+
if (payload.tool_name || payload.toolName || payload.tool_input || payload.arguments)
|
|
95
|
+
return 'mcp';
|
|
96
|
+
if (payload.prompt || payload.text || payload.message)
|
|
97
|
+
return 'prompt';
|
|
98
|
+
if (payload.edits || payload.file_path || payload.path)
|
|
99
|
+
return 'file';
|
|
100
|
+
return 'unknown';
|
|
101
|
+
}
|
|
102
|
+
/** Pull the meaningful text to scan out of a Cursor hook payload, per event. */
|
|
103
|
+
function extractText(event, p) {
|
|
104
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
105
|
+
switch (event) {
|
|
106
|
+
case 'prompt': {
|
|
107
|
+
const direct = str(p.prompt) || str(p.text) || str(p.message) || str(p.content);
|
|
108
|
+
if (direct)
|
|
109
|
+
return direct;
|
|
110
|
+
// Some shapes nest the prompt or send a messages array.
|
|
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
|
+
case 'shell':
|
|
125
|
+
return str(p.command) || str(p.cmd);
|
|
126
|
+
case 'mcp': {
|
|
127
|
+
const name = str(p.tool_name) || str(p.toolName) || str(p.name);
|
|
128
|
+
const input = p.tool_input ?? p.arguments ?? p.input ?? p.params;
|
|
129
|
+
const inputStr = typeof input === 'string' ? input : input ? safeJson(input) : '';
|
|
130
|
+
return [name, inputStr].filter(Boolean).join(': ');
|
|
131
|
+
}
|
|
132
|
+
case 'file': {
|
|
133
|
+
const edits = p.edits;
|
|
134
|
+
if (Array.isArray(edits)) {
|
|
135
|
+
return edits.map((e) => str(e.new_text) || str(e.text) || str(e.content)).filter(Boolean).join('\n');
|
|
136
|
+
}
|
|
137
|
+
return str(p.content) || str(p.new_text) || '';
|
|
138
|
+
}
|
|
139
|
+
case 'read':
|
|
140
|
+
return str(p.file_path) || str(p.path) || '';
|
|
141
|
+
default:
|
|
142
|
+
return str(p.prompt) || str(p.text) || str(p.message) || str(p.command) || '';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function safeJson(v) {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.stringify(v);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return String(v);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Per-event attribution that maps onto the existing Shield runtime headers. */
|
|
154
|
+
function attributionFor(event, p) {
|
|
155
|
+
const h = {};
|
|
156
|
+
const set = (k, v) => { if (v)
|
|
157
|
+
h[k] = v; };
|
|
158
|
+
switch (event) {
|
|
159
|
+
case 'prompt':
|
|
160
|
+
set('x-input-source', 'user');
|
|
161
|
+
set('x-runtime-event-type', 'user_message');
|
|
162
|
+
set('x-runtime-trust', 'trusted_user');
|
|
163
|
+
break;
|
|
164
|
+
case 'shell':
|
|
165
|
+
set('x-input-source', 'tool');
|
|
166
|
+
set('x-runtime-event-type', 'action_request');
|
|
167
|
+
set('x-action-type', 'shell');
|
|
168
|
+
set('x-runtime-sink', 'execute');
|
|
169
|
+
set('x-runtime-trust', 'untrusted');
|
|
170
|
+
break;
|
|
171
|
+
case 'mcp':
|
|
172
|
+
set('x-input-source', 'tool');
|
|
173
|
+
set('x-runtime-event-type', 'tool_call');
|
|
174
|
+
set('x-tool-name', String(p.tool_name || p.toolName || p.name || 'mcp'));
|
|
175
|
+
set('x-runtime-trust', 'untrusted');
|
|
176
|
+
break;
|
|
177
|
+
case 'file':
|
|
178
|
+
set('x-input-source', 'generated');
|
|
179
|
+
set('x-runtime-event-type', 'memory_write');
|
|
180
|
+
break;
|
|
181
|
+
case 'read':
|
|
182
|
+
set('x-input-source', 'tool');
|
|
183
|
+
set('x-runtime-event-type', 'memory_read');
|
|
184
|
+
break;
|
|
185
|
+
default:
|
|
186
|
+
set('x-input-source', 'user');
|
|
187
|
+
}
|
|
188
|
+
return h;
|
|
189
|
+
}
|
|
190
|
+
/** Stable developer identity, used for per-developer attribution on shield events. */
|
|
191
|
+
function developerId() {
|
|
192
|
+
if (process.env.FULLCOURTDEFENSE_DEVELOPER_ID)
|
|
193
|
+
return process.env.FULLCOURTDEFENSE_DEVELOPER_ID;
|
|
194
|
+
try {
|
|
195
|
+
const u = os.userInfo().username;
|
|
196
|
+
return `${u}@${os.hostname()}`;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return 'unknown-developer';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Cursor conversation/session id when present, else a stable per-machine id. */
|
|
203
|
+
function sessionId(p) {
|
|
204
|
+
const fromPayload = p.conversation_id || p.conversationId || p.session_id || p.sessionId;
|
|
205
|
+
if (typeof fromPayload === 'string' && fromPayload)
|
|
206
|
+
return fromPayload;
|
|
207
|
+
return `cursor-${os.hostname()}`;
|
|
208
|
+
}
|
|
209
|
+
async function hookCommand(args, config) {
|
|
210
|
+
const shadow = args.shadow === 'true';
|
|
211
|
+
const failClosed = args.failClosed === 'true';
|
|
212
|
+
const timeoutMs = Number(args.timeout) > 0 ? Number(args.timeout) : 8000;
|
|
213
|
+
const home = loadHomeShield();
|
|
214
|
+
const shieldId = args.shieldId || process.env.FULLCOURTDEFENSE_SHIELD_ID || config.shieldId || home.shieldId;
|
|
215
|
+
const shieldKey = args.shieldKey || process.env.FULLCOURTDEFENSE_SHIELD_KEY || config.shieldKey || home.shieldKey;
|
|
216
|
+
const apiUrl = (args.apiUrl || process.env.FULLCOURTDEFENSE_API_URL || config.apiUrl || home.apiUrl || DEFAULT_API_URL)
|
|
217
|
+
.replace(/\/$/, '');
|
|
218
|
+
let raw = '';
|
|
219
|
+
try {
|
|
220
|
+
raw = await readStdin();
|
|
221
|
+
}
|
|
222
|
+
catch { /* ignore */ }
|
|
223
|
+
let payload = {};
|
|
224
|
+
if (raw.trim()) {
|
|
225
|
+
try {
|
|
226
|
+
payload = JSON.parse(raw);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
payload = {};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const event = inferEvent(args.event, payload);
|
|
233
|
+
const text = extractText(event, payload).trim();
|
|
234
|
+
// Emit the verdict in the shape Cursor expects for THIS event, then exit.
|
|
235
|
+
// beforeSubmitPrompt blocks via { continue: false }; the execution hooks
|
|
236
|
+
// (shell/mcp/read) block via { permission: "deny" }. Exit 2 reinforces it.
|
|
237
|
+
const respond = (blocked, userMsg, agentMsg) => {
|
|
238
|
+
const decision = event === 'prompt'
|
|
239
|
+
? (blocked ? { continue: false } : { continue: true })
|
|
240
|
+
: { permission: blocked ? 'deny' : 'allow' };
|
|
241
|
+
if (userMsg)
|
|
242
|
+
decision.user_message = userMsg;
|
|
243
|
+
if (agentMsg)
|
|
244
|
+
decision.agent_message = agentMsg;
|
|
245
|
+
process.stdout.write(JSON.stringify(decision));
|
|
246
|
+
process.exit(blocked ? 2 : 0);
|
|
247
|
+
};
|
|
248
|
+
// Nothing to scan, or no Shield configured → allow (fail-open by design here:
|
|
249
|
+
// a misconfigured machine must not brick the developer's IDE).
|
|
250
|
+
if (!text)
|
|
251
|
+
respond(false);
|
|
252
|
+
if (!shieldId) {
|
|
253
|
+
respond(false, undefined, 'FullCourtDefense hook installed but no Shield ID configured (run `fullcourtdefense configure`).');
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const controller = new AbortController();
|
|
257
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
258
|
+
// Behaves exactly like a normal Shield agent calling the public proxy:
|
|
259
|
+
// Shield key + runtime/developer attribution headers, body { message }.
|
|
260
|
+
const headers = {
|
|
261
|
+
'Content-Type': 'application/json',
|
|
262
|
+
'x-runtime-source-id': developerId(),
|
|
263
|
+
'x-developer-id': developerId(),
|
|
264
|
+
'x-session-id': sessionId(payload),
|
|
265
|
+
...attributionFor(event, payload),
|
|
266
|
+
};
|
|
267
|
+
if (shieldKey)
|
|
268
|
+
headers['x-shield-key'] = shieldKey;
|
|
269
|
+
const resp = await fetch(`${apiUrl}/api/shield/proxy/${shieldId}`, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers,
|
|
272
|
+
body: JSON.stringify({ message: text }),
|
|
273
|
+
signal: controller.signal,
|
|
274
|
+
});
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
if (!resp.ok) {
|
|
277
|
+
// Backend rejected (quota, bad shield, etc.) — fail open unless told otherwise.
|
|
278
|
+
respond(failClosed, failClosed ? `FullCourtDefense gate unavailable (HTTP ${resp.status}) — blocked by fail-closed policy.` : undefined, `FullCourtDefense gate returned HTTP ${resp.status}.`);
|
|
279
|
+
}
|
|
280
|
+
const data = await resp.json().catch(() => ({}));
|
|
281
|
+
const sh = (data._shield || {});
|
|
282
|
+
const action = String(sh.action || '');
|
|
283
|
+
// Proxy returns 'blocked_input' / 'blocked_output' when it blocks; in monitor
|
|
284
|
+
// mode it returns 'allowed' (never block, even if wouldBlock is set).
|
|
285
|
+
const isBlocked = action.startsWith('blocked');
|
|
286
|
+
const reason = String(sh.reason || 'policy_violation');
|
|
287
|
+
const explanation = String(sh.reason || 'Flagged by FullCourtDefense.');
|
|
288
|
+
if (!isBlocked) {
|
|
289
|
+
respond(false);
|
|
290
|
+
}
|
|
291
|
+
// Blocked verdict. In shadow mode we annotate but let it through.
|
|
292
|
+
if (shadow) {
|
|
293
|
+
respond(false, undefined, `[FullCourtDefense shadow] would block ${event} (${reason}): ${explanation}`);
|
|
294
|
+
}
|
|
295
|
+
respond(true, `Blocked by FullCourtDefense — ${reason}.`, `FullCourtDefense blocked this ${event} as "${reason}". Do not retry; revise to remove the flagged content.`);
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
// Network error / timeout.
|
|
299
|
+
respond(failClosed, failClosed ? 'FullCourtDefense gate unreachable — blocked by fail-closed policy.' : undefined, `FullCourtDefense hook error: ${err instanceof Error ? err.message : String(err)}.`);
|
|
300
|
+
}
|
|
301
|
+
}
|