fullcourtdefense-cli 1.7.16 → 1.7.18

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,12 @@
1
+ import { BotGuardConfig } from '../config';
2
+ export interface AgentCiArgs {
3
+ failOn?: string;
4
+ format?: string;
5
+ upload?: string;
6
+ requireGateway?: string;
7
+ requireApprovalFor?: string;
8
+ extraPath?: string;
9
+ apiKey?: string;
10
+ apiUrl?: string;
11
+ }
12
+ export declare function agentCiCommand(args: AgentCiArgs, config: BotGuardConfig): Promise<void>;
@@ -0,0 +1,303 @@
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.agentCiCommand = agentCiCommand;
37
+ const crypto = __importStar(require("crypto"));
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const config_1 = require("../config");
41
+ const discoverPaths_1 = require("./discoverPaths");
42
+ const discoverAgentFiles_1 = require("./discoverAgentFiles");
43
+ const discoverSecrets_1 = require("./discoverSecrets");
44
+ const discover_1 = require("./discover");
45
+ const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
46
+ function stableId(parts) {
47
+ return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
48
+ }
49
+ function parseJsonFile(filePath) {
50
+ try {
51
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function parseTomlMcpServers(content) {
58
+ const servers = {};
59
+ const lines = content.split(/\r?\n/);
60
+ let current = null;
61
+ for (const raw of lines) {
62
+ const line = raw.trim();
63
+ const section = line.match(/^\[mcp_servers\.([^\]]+)\]$/);
64
+ if (section) {
65
+ current = section[1].replace(/^["']|["']$/g, '');
66
+ servers[current] = {};
67
+ continue;
68
+ }
69
+ if (!current || !line || line.startsWith('#'))
70
+ continue;
71
+ const match = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
72
+ if (!match)
73
+ continue;
74
+ const key = match[1];
75
+ const rawValue = match[2].trim().replace(/,$/, '');
76
+ if (rawValue.startsWith('[')) {
77
+ servers[current][key] = rawValue
78
+ .replace(/^\[|\]$/g, '')
79
+ .split(',')
80
+ .map(part => part.trim().replace(/^["']|["']$/g, ''))
81
+ .filter(Boolean);
82
+ }
83
+ else {
84
+ servers[current][key] = rawValue.replace(/^["']|["']$/g, '');
85
+ }
86
+ }
87
+ return servers;
88
+ }
89
+ function extractServerMap(parsed) {
90
+ if (!parsed || typeof parsed !== 'object')
91
+ return {};
92
+ if (parsed.mcpServers && typeof parsed.mcpServers === 'object')
93
+ return parsed.mcpServers;
94
+ if (parsed.servers && typeof parsed.servers === 'object')
95
+ return parsed.servers;
96
+ if (parsed.mcp?.servers && typeof parsed.mcp.servers === 'object')
97
+ return parsed.mcp.servers;
98
+ if (parsed['mcp.servers'] && typeof parsed['mcp.servers'] === 'object')
99
+ return parsed['mcp.servers'];
100
+ return {};
101
+ }
102
+ function riskSignalsForServer(name, server) {
103
+ const text = [
104
+ name,
105
+ server?.command,
106
+ ...(Array.isArray(server?.args) ? server.args : []),
107
+ server?.url,
108
+ ].filter(Boolean).join(' ').toLowerCase();
109
+ const signals = [
110
+ [/\b(shell|exec|command|terminal|powershell|bash|cmd\.exe)\b/, 'shell'],
111
+ [/\b(file|filesystem|readfile|writefile|directory|path)\b/, 'filesystem'],
112
+ [/\b(database|postgres|mysql|mongo|sql|db)\b/, 'database'],
113
+ [/\b(secret|token|key|password|credential|env)\b/, 'secrets'],
114
+ [/\b(payment|wire|transfer|bank|iban|invoice|billing)\b/, 'payment'],
115
+ [/\b(customer|user|pii|ssn|email|phone|record)\b/, 'pii'],
116
+ [/\b(browser|web|http|fetch|request|webhook)\b/, 'network'],
117
+ [/\b(delete|remove|destroy|drop|wipe|purge|write|update|create)\b/, 'write'],
118
+ ];
119
+ return signals.filter(([re]) => re.test(text)).map(([, signal]) => signal);
120
+ }
121
+ function discoverMcpServers(cwd, extraPath) {
122
+ const out = [];
123
+ for (const candidate of (0, discoverPaths_1.discoverScanTargets)(cwd, extraPath)) {
124
+ if (!fs.existsSync(candidate.path))
125
+ continue;
126
+ const isToml = candidate.path.endsWith('.toml');
127
+ const parsed = isToml
128
+ ? parseTomlMcpServers(fs.readFileSync(candidate.path, 'utf8'))
129
+ : parseJsonFile(candidate.path);
130
+ const map = extractServerMap(parsed);
131
+ for (const [name, server] of Object.entries(map)) {
132
+ const cfg = server;
133
+ const command = typeof cfg.command === 'string' ? cfg.command : undefined;
134
+ const args = Array.isArray(cfg.args) ? cfg.args.map(String) : [];
135
+ const url = typeof cfg.url === 'string' ? cfg.url : typeof cfg.endpoint === 'string' ? cfg.endpoint : undefined;
136
+ const targetText = [command, ...args, url].filter(Boolean).join(' ');
137
+ const protectedGateway = /fullcourtdefense|agentguard|mcp-gateway|5066|fcd/i.test(targetText);
138
+ out.push({
139
+ name,
140
+ source: candidate.source,
141
+ filePath: candidate.path,
142
+ command,
143
+ args,
144
+ url,
145
+ direct: !protectedGateway,
146
+ protectedGateway,
147
+ riskSignals: riskSignalsForServer(name, cfg),
148
+ });
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+ function parseRequiredControls(value) {
154
+ return (value || 'payment,delete,shell,secrets')
155
+ .split(',')
156
+ .map(part => part.trim().toLowerCase())
157
+ .filter(Boolean);
158
+ }
159
+ function finding(input) {
160
+ return { ...input, id: stableId([input.category, input.title, input.filePath || '', input.detail]) };
161
+ }
162
+ function evaluateAgentGate(args) {
163
+ const cwd = process.cwd();
164
+ const mcpServers = discoverMcpServers(cwd, args.extraPath);
165
+ const agentFiles = (0, discoverAgentFiles_1.scanAgentFiles)({ cwd });
166
+ const secrets = (0, discoverSecrets_1.scanSecrets)({ cwd, maxEnvDepth: 3 });
167
+ const requireGateway = args.requireGateway !== 'false';
168
+ const requiredControls = parseRequiredControls(args.requireApprovalFor);
169
+ const findings = [];
170
+ for (const server of mcpServers) {
171
+ if (requireGateway && server.direct && server.riskSignals.length > 0) {
172
+ findings.push(finding({
173
+ severity: server.riskSignals.includes('payment') || server.riskSignals.includes('shell') || server.riskSignals.includes('secrets') ? 'critical' : 'high',
174
+ category: 'mcp',
175
+ title: `Unprotected MCP server: ${server.name}`,
176
+ detail: `${server.source} exposes ${server.riskSignals.join(', ')} without Full Court Defense gateway wrapping.`,
177
+ filePath: server.filePath,
178
+ remediation: 'Wrap this MCP server with fullcourtdefense protect-all or install-mcp-gateway before merging.',
179
+ }));
180
+ }
181
+ const missingControls = server.riskSignals.filter(signal => requiredControls.includes(signal));
182
+ if (missingControls.length > 0 && server.direct) {
183
+ findings.push(finding({
184
+ severity: 'high',
185
+ category: 'policy',
186
+ title: `Missing approval policy evidence for ${server.name}`,
187
+ detail: `Risky tool signals need approval/block policy before merge: ${missingControls.join(', ')}.`,
188
+ filePath: server.filePath,
189
+ remediation: 'Add an AgentGuard action policy requiring approval/block for this tool class, then route through the gateway.',
190
+ }));
191
+ }
192
+ }
193
+ for (const file of agentFiles.filter(item => item.riskTags.length > 0)) {
194
+ findings.push(finding({
195
+ severity: file.riskTags.includes('allows_shell') || file.riskTags.includes('broad_filesystem') ? 'high' : 'medium',
196
+ category: 'agent-file',
197
+ title: `Risky agent instruction: ${file.title}`,
198
+ detail: `${file.filePath} contains agent risk tags: ${file.riskTags.join(', ')}.`,
199
+ filePath: file.filePath,
200
+ remediation: 'Review the instruction/rule and add explicit least-privilege boundaries.',
201
+ }));
202
+ }
203
+ for (const secret of secrets.findings.filter(item => item.severity === 'critical' || item.severity === 'high')) {
204
+ findings.push(finding({
205
+ severity: secret.severity === 'critical' ? 'critical' : 'high',
206
+ category: 'secret',
207
+ title: secret.title,
208
+ detail: secret.detail,
209
+ filePath: secret.filePath,
210
+ remediation: secret.remediation,
211
+ }));
212
+ }
213
+ let score = 100;
214
+ for (const item of findings) {
215
+ score -= item.severity === 'critical' ? 25 : item.severity === 'high' ? 12 : item.severity === 'medium' ? 5 : 2;
216
+ }
217
+ return { findings: findings.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]), score: Math.max(0, score), mcpServers };
218
+ }
219
+ function shouldFail(findings, failOn) {
220
+ return findings.some(item => SEVERITY_RANK[item.severity] >= SEVERITY_RANK[failOn]);
221
+ }
222
+ function toSarif(findings) {
223
+ return {
224
+ version: '2.1.0',
225
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
226
+ runs: [{
227
+ tool: {
228
+ driver: {
229
+ name: 'Full Court Defense Agent CI Gate',
230
+ informationUri: 'https://fullcourtdefense.ai',
231
+ rules: findings.map(item => ({
232
+ id: item.id,
233
+ name: item.title,
234
+ shortDescription: { text: item.title },
235
+ fullDescription: { text: item.detail },
236
+ help: { text: item.remediation },
237
+ defaultConfiguration: {
238
+ level: item.severity === 'critical' || item.severity === 'high' ? 'error' : item.severity === 'medium' ? 'warning' : 'note',
239
+ },
240
+ })),
241
+ },
242
+ },
243
+ results: findings.map(item => ({
244
+ ruleId: item.id,
245
+ level: item.severity === 'critical' || item.severity === 'high' ? 'error' : item.severity === 'medium' ? 'warning' : 'note',
246
+ message: { text: `${item.title}: ${item.detail}` },
247
+ locations: item.filePath ? [{
248
+ physicalLocation: {
249
+ artifactLocation: { uri: path.relative(process.cwd(), item.filePath).replace(/\\/g, '/') },
250
+ region: { startLine: 1 },
251
+ },
252
+ }] : [],
253
+ })),
254
+ }],
255
+ };
256
+ }
257
+ function printSummary(report, failed, failOn) {
258
+ console.log(`Agent CI Gate: ${failed ? 'FAIL' : 'PASS'}`);
259
+ console.log(`Score: ${report.score}/100`);
260
+ console.log(`MCP servers: ${report.mcpServers.length}`);
261
+ console.log(`Findings: ${report.findings.length} (fail-on: ${failOn})`);
262
+ for (const item of report.findings.slice(0, 20)) {
263
+ console.log(`- [${item.severity.toUpperCase()}] ${item.title}`);
264
+ console.log(` ${item.detail}`);
265
+ if (item.filePath)
266
+ console.log(` ${path.relative(process.cwd(), item.filePath)}`);
267
+ }
268
+ if (report.findings.length > 20)
269
+ console.log(`... and ${report.findings.length - 20} more findings`);
270
+ }
271
+ async function agentCiCommand(args, config) {
272
+ const failOn = (['critical', 'high', 'medium', 'low'].includes(String(args.failOn || '').toLowerCase())
273
+ ? String(args.failOn).toLowerCase()
274
+ : 'high');
275
+ const format = (['summary', 'json', 'sarif'].includes(String(args.format || '').toLowerCase())
276
+ ? String(args.format).toLowerCase()
277
+ : 'summary');
278
+ const report = evaluateAgentGate(args);
279
+ const failed = shouldFail(report.findings, failOn);
280
+ if (format === 'json') {
281
+ console.log(JSON.stringify({ ...report, failed, failOn }, null, 2));
282
+ }
283
+ else if (format === 'sarif') {
284
+ console.log(JSON.stringify(toSarif(report.findings), null, 2));
285
+ }
286
+ else {
287
+ printSummary(report, failed, failOn);
288
+ }
289
+ if (args.upload === 'true') {
290
+ const creds = (0, config_1.resolveCliCredentials)(config, { apiKey: args.apiKey, apiUrl: args.apiUrl });
291
+ if (creds.apiKey || (creds.shieldId && creds.shieldKey)) {
292
+ await (0, discover_1.runDiscoverUpload)({ apiKey: args.apiKey, apiUrl: args.apiUrl, silent: true }, config).catch(error => {
293
+ if (format === 'summary')
294
+ console.error(`Upload skipped: ${error instanceof Error ? error.message : String(error)}`);
295
+ });
296
+ }
297
+ else if (format === 'summary') {
298
+ console.error('Upload skipped: run fullcourtdefense login first or pass --api-key.');
299
+ }
300
+ }
301
+ if (failed)
302
+ process.exit(1);
303
+ }
@@ -1111,7 +1111,7 @@ function compactScanHtml(args) {
1111
1111
  .workflowCard { min-height: 118px; }
1112
1112
  .workflowCard b { font-size: 18px; }
1113
1113
  .workflowCard span { font-size: 13px; }
1114
- .landingScanGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
1114
+ .landingScanGrid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
1115
1115
  .scanTypeGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 12px 0; }
1116
1116
  .advancedScanGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 8px 0 12px; }
1117
1117
  .card:hover, .card.active { border-color: #2563eb; background: #eff6ff; transform: translateY(-1px); }
@@ -1203,6 +1203,7 @@ function compactScanHtml(args) {
1203
1203
  <button class="card workflowCard" data-workflow="bot"><b>Bot Scan</b><span>Red-team an internal bot, agent, or chat API.</span></button>
1204
1204
  <button class="card workflowCard" data-workflow="posture"><b>Security Posture Scan</b><span>Run web-style endpoint security checkup.</span></button>
1205
1205
  <button class="card workflowCard" data-workflow="mcp"><b>MCP Scan</b><span>Scan an MCP server or protected gateway for risky tool paths.</span></button>
1206
+ <button class="card workflowCard" data-workflow="mcp-posture"><b>MCP Posture Scan</b><span>Inventory exposed MCP tools without invoking them.</span></button>
1206
1207
  </div>
1207
1208
  </div>
1208
1209
  <div class="menuGroup">
@@ -1281,6 +1282,7 @@ function compactScanHtml(args) {
1281
1282
  <div class="advancedScanGrid">
1282
1283
  <button class="card scanTypeCard" data-target="rag"><b>RAG Scan</b><span>Test retrieval URL or local corpus path.</span></button>
1283
1284
  <button class="card scanTypeCard" data-target="mcp"><b>MCP Scan</b><span>Scan tool responses and tool-abuse paths.</span></button>
1285
+ <button class="card scanTypeCard" data-target="mcp-posture"><b>MCP Posture Scan</b><span>List tools and flag risky exposed capabilities.</span></button>
1284
1286
  </div>
1285
1287
  </div>
1286
1288
 
@@ -1329,6 +1331,7 @@ function compactScanHtml(args) {
1329
1331
  </div>
1330
1332
 
1331
1333
  <div id="mcp-fields" class="hidden">
1334
+ <div class="note" id="mcpModeNote">MCP Scan runs red-team templates against MCP tool behavior. MCP Posture Scan lists exposed tools and flags risky capabilities without invoking them.</div>
1332
1335
  <div class="two">
1333
1336
  <div><label>MCP HTTP/SSE URL</label><input id="mcpUrl" placeholder="http://127.0.0.1:5066/mcp" /></div>
1334
1337
  <div><label>MCP tool</label><input id="mcpTool" value="all" /></div>
@@ -1414,19 +1417,25 @@ Local targets stay on this machine; saved scan reports go to Full Court Defense.
1414
1417
  </section>
1415
1418
  </main>
1416
1419
  <script>
1417
- const scanTargets = ['endpoint','posture','rag','mcp'];
1420
+ const scanTargets = ['endpoint','posture','rag','mcp','mcp-posture'];
1418
1421
  let target = 'home';
1419
1422
  let step = 1;
1420
1423
  const qs = new URLSearchParams(location.search);
1421
- if (['runner','endpoint','posture','rag','mcp','help'].includes(qs.get('target'))) target = qs.get('target');
1424
+ if (['runner','endpoint','posture','rag','mcp','mcp-posture','help'].includes(qs.get('target'))) target = qs.get('target');
1422
1425
  const byId = id => document.getElementById(id);
1423
1426
  const workflow = () => target === 'endpoint' ? 'bot' : target === 'posture' ? 'posture' : target === 'home' ? '' : target;
1424
1427
  const sync = () => {
1425
1428
  document.querySelectorAll('.workflowCard').forEach(c => c.classList.toggle('active', c.dataset.workflow === workflow()));
1426
1429
  document.querySelectorAll('.scanTypeCard').forEach(c => c.classList.toggle('active', c.dataset.target === target));
1427
1430
  byId('workflowPanel').classList.toggle('hidden', target === 'home');
1428
- ['runner','endpoint','posture','rag','mcp','help'].forEach(id => byId(id + '-fields').classList.toggle('hidden', id !== target));
1431
+ ['runner','endpoint','posture','rag','mcp','help'].forEach(id => {
1432
+ const show = id === 'mcp' ? ['mcp','mcp-posture'].includes(target) : id === target;
1433
+ byId(id + '-fields').classList.toggle('hidden', !show);
1434
+ });
1429
1435
  const scanTarget = scanTargets.includes(target);
1436
+ byId('mcpModeNote').textContent = target === 'mcp-posture'
1437
+ ? 'MCP Posture Scan lists exposed tools, checks whether the server appears protected, and flags risky capabilities without invoking any tool.'
1438
+ : 'MCP Scan runs red-team templates against MCP tool behavior. Use MCP Posture Scan for inventory-only posture.';
1430
1439
  byId('scan-picker').classList.toggle('hidden', !scanTarget);
1431
1440
  byId('scanSteps').classList.toggle('hidden', !scanTarget);
1432
1441
  byId('scanActions').classList.toggle('hidden', !scanTarget);
@@ -1434,12 +1443,16 @@ Local targets stay on this machine; saved scan reports go to Full Court Defense.
1434
1443
  ? 'Run MCP Tool'
1435
1444
  : target === 'help'
1436
1445
  ? 'Local workflow options'
1437
- : 'Choose scan target';
1446
+ : target === 'mcp-posture'
1447
+ ? 'MCP posture scan'
1448
+ : 'Choose scan target';
1438
1449
  byId('connectSubtitle').textContent = target === 'runner'
1439
1450
  ? 'Call one MCP tool from this machine. This is not a scan.'
1440
1451
  : target === 'help'
1441
1452
  ? 'Choose one-off tool run or local red-team scan.'
1442
- : 'Select Endpoint, RAG, or MCP, then connect the internal target.';
1453
+ : target === 'mcp-posture'
1454
+ ? 'Connect an MCP server or protected gateway. No tools are invoked.'
1455
+ : 'Select Endpoint, RAG, or MCP, then connect the internal target.';
1443
1456
  if (!scanTarget || target === 'home') setStep(1);
1444
1457
  };
1445
1458
  const setStep = next => {
@@ -1512,6 +1525,7 @@ Local targets stay on this machine; saved scan reports go to Full Court Defense.
1512
1525
  if (p.target === 'endpoint') parts.push('--endpoint', JSON.stringify(p.endpoint), '--method POST --request-format custom --input-field', p.inputField || 'message', '--output-field', p.outputField || 'response');
1513
1526
  if (p.target === 'posture') return 'Security Posture Scan runs through this local web UI and calls the Full Court Defense posture checkup API for ' + (p.postureUrl || '<endpoint-url>');
1514
1527
  if (p.target === 'rag') { if (p.ragUrl) parts.push('--rag-url', JSON.stringify(p.ragUrl), '--method POST --request-format custom --input-field', p.ragInputField || 'query', '--output-field', p.ragOutputField || 'chunks'); else parts.push('--rag-path', JSON.stringify(p.ragPath || '.\\\\docs')); }
1528
+ if (p.target === 'mcp-posture') return 'MCP Posture Scan runs through this local web UI and inventories exposed tools for ' + (p.mcpUrl || ((p.mcpCommand || 'node') + ' ' + (p.mcpArgs || '.\\\\server.js')));
1515
1529
  if (p.target === 'mcp') { if (p.mcpUrl) parts.push('--mcp-url', JSON.stringify(p.mcpUrl)); else parts.push('--mcp-command', p.mcpCommand || 'node', '--mcp-args', JSON.stringify(p.mcpArgs || '.\\\\server.js')); parts.push('--mcp-tool', p.mcpTool || 'all'); }
1516
1530
  return parts.join(' ');
1517
1531
  };
@@ -1767,6 +1781,20 @@ Local targets stay on this machine; saved scan reports go to Full Court Defense.
1767
1781
  byId('run').disabled = true;
1768
1782
  byId('run').textContent = 'Running...';
1769
1783
  clearScanSummary();
1784
+ if (target === 'mcp-posture') {
1785
+ try {
1786
+ setConsole('Starting MCP Posture Scan...\\nNo MCP tools will be invoked; this only lists exposed tools.\\n', 'Running MCP posture');
1787
+ const postureRes = await fetch('/mcp/posture', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload()) });
1788
+ const postureData = await postureRes.json();
1789
+ setConsole(JSON.stringify(postureData, null, 2), postureData.ok ? 'MCP posture complete' : 'MCP posture failed');
1790
+ } catch (error) {
1791
+ setConsole('MCP Posture Scan failed: ' + (error && error.message ? error.message : String(error)), 'MCP posture failed');
1792
+ } finally {
1793
+ byId('run').disabled = false;
1794
+ byId('run').textContent = 'Run local scan';
1795
+ }
1796
+ return;
1797
+ }
1770
1798
  const backendOk = await checkBackend();
1771
1799
  if (!backendOk) {
1772
1800
  setConsole('Outbound backend check failed. Fix backend connectivity before scanning so verdicts and reports can be generated.', 'Blocked');
@@ -2039,6 +2067,84 @@ async function runMcpToolFromBrowser(body) {
2039
2067
  client.close();
2040
2068
  }
2041
2069
  }
2070
+ function mcpPostureSignals(tool) {
2071
+ const text = `${tool.name || ''} ${tool.description || ''} ${JSON.stringify(tool.inputSchema || {})}`.toLowerCase();
2072
+ const checks = [
2073
+ [/\b(delete|remove|destroy|drop|wipe|purge|erase)\b/, 'destructive operation'],
2074
+ [/\b(write|update|create|insert|modify|patch|replace)\b/, 'write-capable operation'],
2075
+ [/\b(exec|shell|command|process|spawn|powershell|bash|terminal)\b/, 'shell/command execution'],
2076
+ [/\b(file|filesystem|path|readfile|writefile|directory|upload|download)\b/, 'file-system access'],
2077
+ [/\b(secret|token|key|password|credential|env|private)\b/, 'secret/credential access'],
2078
+ [/\b(payment|wire|transfer|invoice|billing|bank|account|iban)\b/, 'financial operation'],
2079
+ [/\b(http|request|fetch|webhook|url|network|browser)\b/, 'network/browser access'],
2080
+ [/\b(customer|user|email|phone|address|ssn|pii|record)\b/, 'customer/PII data access'],
2081
+ ];
2082
+ return checks.filter(([pattern]) => pattern.test(text)).map(([, label]) => label);
2083
+ }
2084
+ async function runMcpPostureScanFromBrowser(body) {
2085
+ const rawUrl = typeof body.mcpUrl === 'string' ? body.mcpUrl.trim() : '';
2086
+ const rawCommand = typeof body.mcpCommand === 'string' ? body.mcpCommand.trim() : '';
2087
+ const rawArgs = typeof body.mcpArgs === 'string' ? body.mcpArgs.trim() : '';
2088
+ if (!rawUrl && !rawCommand)
2089
+ throw new Error('Provide either an MCP HTTP/SSE URL or stdio command.');
2090
+ const transport = rawUrl
2091
+ ? (/\/sse\/?$/.test(new URL(rawUrl).pathname) ? 'sse' : 'http')
2092
+ : 'stdio';
2093
+ const options = {
2094
+ transport,
2095
+ url: rawUrl || undefined,
2096
+ command: rawCommand || undefined,
2097
+ args: parseArgsList(rawArgs),
2098
+ authType: 'none',
2099
+ toolName: 'all',
2100
+ toolArgs: {},
2101
+ };
2102
+ const destination = rawUrl || `${rawCommand} ${options.args?.join(' ') || ''}`.trim();
2103
+ const protectedGatewayDetected = /fullcourtdefense|agentguard|fcd|5066|gateway|proxy/i.test(destination);
2104
+ const client = createMcpClient(options);
2105
+ const startedAt = Date.now();
2106
+ try {
2107
+ await client.initialize();
2108
+ const tools = await client.listTools();
2109
+ const toolFindings = tools.map(tool => {
2110
+ const signals = mcpPostureSignals(tool);
2111
+ return {
2112
+ name: tool.name,
2113
+ description: tool.description || '',
2114
+ risk: signals.length >= 3 ? 'high' : signals.length >= 1 ? 'medium' : 'low',
2115
+ signals,
2116
+ };
2117
+ });
2118
+ const highRiskTools = toolFindings.filter(tool => tool.risk === 'high').length;
2119
+ const mediumRiskTools = toolFindings.filter(tool => tool.risk === 'medium').length;
2120
+ const findings = [
2121
+ ...(protectedGatewayDetected ? [] : ['MCP target does not look like a protected Full Court Defense gateway/proxy.']),
2122
+ ...(highRiskTools ? [`${highRiskTools} high-risk MCP tool(s) exposed.`] : []),
2123
+ ...(mediumRiskTools ? [`${mediumRiskTools} medium-risk MCP tool(s) exposed.`] : []),
2124
+ ...(tools.length > 20 ? [`Large MCP surface: ${tools.length} tools exposed.`] : []),
2125
+ ];
2126
+ return {
2127
+ ok: true,
2128
+ scanType: 'mcp_posture',
2129
+ posture: findings.length === 0 ? 'pass' : highRiskTools || !protectedGatewayDetected ? 'fail' : 'warn',
2130
+ transport,
2131
+ destination,
2132
+ protectedGatewayDetected,
2133
+ elapsedMs: Date.now() - startedAt,
2134
+ summary: {
2135
+ totalTools: tools.length,
2136
+ highRiskTools,
2137
+ mediumRiskTools,
2138
+ lowRiskTools: toolFindings.filter(tool => tool.risk === 'low').length,
2139
+ },
2140
+ findings,
2141
+ tools: toolFindings,
2142
+ };
2143
+ }
2144
+ finally {
2145
+ client.close();
2146
+ }
2147
+ }
2042
2148
  function startCompactWebServer(args) {
2043
2149
  const server = http.createServer(async (req, res) => {
2044
2150
  try {
@@ -2100,6 +2206,13 @@ function startCompactWebServer(args) {
2100
2206
  res.end(JSON.stringify(result));
2101
2207
  return;
2102
2208
  }
2209
+ if (req.method === 'POST' && url.pathname === '/mcp/posture') {
2210
+ const body = await readRequestBody(req);
2211
+ const result = await runMcpPostureScanFromBrowser(body);
2212
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
2213
+ res.end(JSON.stringify(result));
2214
+ return;
2215
+ }
2103
2216
  if (req.method === 'POST' && url.pathname === '/posture/run') {
2104
2217
  const body = await readRequestBody(req);
2105
2218
  const result = await runPostureScanFromBrowser(args, body);
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ const configure_1 = require("./commands/configure");
43
43
  const login_1 = require("./commands/login");
44
44
  const flushSpool_1 = require("./commands/flushSpool");
45
45
  const discover_1 = require("./commands/discover");
46
+ const agentCi_1 = require("./commands/agentCi");
46
47
  const hook_1 = require("./commands/hook");
47
48
  const installCursorHook_1 = require("./commands/installCursorHook");
48
49
  const mcpGateway_1 = require("./commands/mcpGateway");
@@ -125,6 +126,8 @@ function printHelp() {
125
126
  discover Finds MCP servers, local secrets, agent rules/skills, and machine
126
127
  blast radius on this laptop. Use --surface all --upload for AI Fleet.
127
128
  Use --schedule logon to upload inventory/posture at machine login.
129
+ agent-ci CI/CD gate for agents and tools only. Fails builds on risky MCP,
130
+ agent instruction, gateway, policy, or secret drift. Not a bot scan.
128
131
  install-cursor-hook
129
132
  Installs a Cursor hook so EVERY agent action on this machine is
130
133
  checked against your org's Action Policies — in any repo/folder.
@@ -218,6 +221,8 @@ function printHelp() {
218
221
  \x1b[1mRecommended Commands:\x1b[0m
219
222
  fullcourtdefense doctor
220
223
  fullcourtdefense configure
224
+ fullcourtdefense agent-ci --fail-on high --require-gateway --format summary
225
+ fullcourtdefense agent-ci --fail-on high --format sarif --upload
221
226
  fullcourtdefense scan --local
222
227
  fullcourtdefense scan --local --type mcp --mcp-command node --mcp-args ./server.js --mcp-tool all --mode full --format report
223
228
  fullcourtdefense scan --local --type mcp --mcp-url https://internal.company.com/mcp --mcp-auth-type bearer --mcp-token TOKEN --mcp-tool all --mode full --format report
@@ -256,6 +261,9 @@ function printHelp() {
256
261
  --rag-url <url> Live RAG HTTP endpoint to scan
257
262
  --shield-id <id> Shield ID for local outbound Shield verdicts
258
263
  --shield-key <key> Optional Shield key for locked Shields
264
+ --fail-on <severity> agent-ci fail threshold: critical, high, medium, low
265
+ --require-gateway agent-ci requires risky MCP servers to use FCD gateway
266
+ --require-approval-for agent-ci risky controls list, e.g. payment,delete,shell,secrets
259
267
  --method <GET|POST> Local endpoint method
260
268
  --request-format <fmt> Local endpoint request format: custom or openai
261
269
  --auth-type <type> none, bearer, basic, api-key
@@ -489,6 +497,20 @@ async function main() {
489
497
  await (0, discover_1.discoverCommand)(args, config);
490
498
  break;
491
499
  }
500
+ case 'agent-ci': {
501
+ const args = {
502
+ failOn: flags['fail-on'],
503
+ format: flags.format,
504
+ upload: flags.upload,
505
+ requireGateway: flags['require-gateway'],
506
+ requireApprovalFor: flags['require-approval-for'],
507
+ extraPath: flags.path,
508
+ apiKey: flags['api-key'],
509
+ apiUrl: flags['api-url'],
510
+ };
511
+ await (0, agentCi_1.agentCiCommand)(args, config);
512
+ break;
513
+ }
492
514
  case 'install-cursor-hook': {
493
515
  const args = {
494
516
  project: flags.project,
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.7.16"
2
+ "version": "1.7.18"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.7.16",
3
+ "version": "1.7.18",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {