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 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: ${BOTGUARD_API_KEY}
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:BOTGUARD_API_KEY = "bg_live_..."
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:BOTGUARD_API_KEY" --endpoint "https://agent.example.com/chat" --description "Production support agent" --mode sync --format summary --fail-threshold 80
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
@@ -69,6 +69,7 @@ export interface ScanResult {
69
69
  };
70
70
  agentDescription: string;
71
71
  createdAt: string;
72
+ reportUrl?: string;
72
73
  }
73
74
  export interface AsyncJobResponse {
74
75
  jobId: string;
@@ -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, BOTGUARD_API_KEY env var, or .botguard.yml');
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
+ }