opena2a-cli 0.5.11 → 0.6.0
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 +21 -1
- package/dist/commands/atp-types.d.ts +4 -0
- package/dist/commands/atp-types.d.ts.map +1 -1
- package/dist/commands/claim.js +1 -1
- package/dist/commands/claim.js.map +1 -1
- package/dist/commands/detect.d.ts +41 -18
- package/dist/commands/detect.d.ts.map +1 -1
- package/dist/commands/detect.js +729 -120
- package/dist/commands/detect.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +4 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/mcp-audit.js +1 -1
- package/dist/commands/mcp-audit.js.map +1 -1
- package/dist/commands/review.d.ts +40 -3
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +171 -31
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/trust.js +19 -2
- package/dist/commands/trust.js.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/report/detect-html.d.ts +7 -0
- package/dist/report/detect-html.d.ts.map +1 -0
- package/dist/report/detect-html.js +185 -0
- package/dist/report/detect-html.js.map +1 -0
- package/dist/report/review-html.d.ts +1 -1
- package/dist/report/review-html.d.ts.map +1 -1
- package/dist/report/review-html.js +8 -440
- package/dist/report/review-html.js.map +1 -1
- package/dist/util/report-submission.d.ts +1 -0
- package/dist/util/report-submission.d.ts.map +1 -1
- package/dist/util/report-submission.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/detect.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* opena2a detect -- Shadow AI Agent Audit
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Discovers AI agents running on this machine, MCP servers configured
|
|
6
|
+
* across all platforms, local LLM processes, and AI config files in the
|
|
7
|
+
* project. Reports identity, governance posture, and risk classification.
|
|
7
8
|
*/
|
|
8
9
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
10
|
if (k2 === undefined) k2 = k;
|
|
@@ -42,6 +43,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
42
43
|
exports.scanProcesses = scanProcesses;
|
|
43
44
|
exports.parseMcpConfig = parseMcpConfig;
|
|
44
45
|
exports.scanMcpServers = scanMcpServers;
|
|
46
|
+
exports.scanAiConfigs = scanAiConfigs;
|
|
45
47
|
exports.scanIdentity = scanIdentity;
|
|
46
48
|
exports.detect = detect;
|
|
47
49
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -49,26 +51,58 @@ const fs = __importStar(require("node:fs"));
|
|
|
49
51
|
const path = __importStar(require("node:path"));
|
|
50
52
|
const os = __importStar(require("node:os"));
|
|
51
53
|
const colors_js_1 = require("../util/colors.js");
|
|
52
|
-
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Agent patterns
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
53
57
|
const AGENT_PATTERNS = [
|
|
54
|
-
|
|
55
|
-
{ name: '
|
|
56
|
-
{ name: '
|
|
57
|
-
{ name: '
|
|
58
|
-
{ name: '
|
|
59
|
-
{ name: '
|
|
60
|
-
{ name: '
|
|
58
|
+
// AI coding assistants
|
|
59
|
+
{ name: 'Claude Code', category: 'ai-assistant', patterns: [/@anthropic-ai\/claude-code/i, /\bclaude\s*$/im, /\bclaude\s+/i] },
|
|
60
|
+
{ name: 'Cursor', category: 'ai-assistant', patterns: [/Cursor\.app/i, /cursor-agent/i] },
|
|
61
|
+
{ name: 'GitHub Copilot', category: 'ai-assistant', patterns: [/\bcopilot\b/i] },
|
|
62
|
+
{ name: 'Windsurf', category: 'ai-assistant', patterns: [/Windsurf\.app/i, /windsurf-agent/i] },
|
|
63
|
+
{ name: 'Aider', category: 'ai-assistant', patterns: [/\baider\b/] },
|
|
64
|
+
{ name: 'Continue', category: 'ai-assistant', patterns: [/continue-server/i, /\bcontinue\.dev\b/i] },
|
|
65
|
+
{ name: 'Cline', category: 'ai-assistant', patterns: [/\bcline\b/] },
|
|
66
|
+
{ name: 'Amazon Q', category: 'ai-assistant', patterns: [/\bamazon-q\b/i, /\bq-developer\b/i] },
|
|
67
|
+
{ name: 'Tabnine', category: 'ai-assistant', patterns: [/\btabnine\b/i] },
|
|
68
|
+
{ name: 'Sourcegraph Cody', category: 'ai-assistant', patterns: [/\bcody\b/i, /sourcegraph.*cody/i] },
|
|
69
|
+
{ name: 'Supermaven', category: 'ai-assistant', patterns: [/\bsupermaven\b/i] },
|
|
70
|
+
{ name: 'Augment Code', category: 'ai-assistant', patterns: [/\baugment\b/i] },
|
|
71
|
+
// Local LLM runtimes
|
|
72
|
+
{ name: 'Ollama', category: 'local-llm', patterns: [/\bollama\b/] },
|
|
73
|
+
{ name: 'LM Studio', category: 'local-llm', patterns: [/lmstudio/i, /LM Studio/] },
|
|
74
|
+
{ name: 'LocalAI', category: 'local-llm', patterns: [/\blocalai\b/i] },
|
|
75
|
+
{ name: 'llama.cpp', category: 'local-llm', patterns: [/llama-server/i, /llama\.cpp/i, /\bllama-cli\b/i] },
|
|
76
|
+
{ name: 'vLLM', category: 'local-llm', patterns: [/\bvllm\b/i] },
|
|
77
|
+
{ name: 'Open WebUI', category: 'local-llm', patterns: [/open-webui/i] },
|
|
78
|
+
{ name: 'GPT4All', category: 'local-llm', patterns: [/\bgpt4all\b/i] },
|
|
79
|
+
{ name: 'Jan', category: 'local-llm', patterns: [/\bjan\.app\b/i, /Jan\.app/] },
|
|
61
80
|
];
|
|
62
81
|
const MCP_CONFIG_LOCATIONS = [
|
|
63
|
-
{ path: '.claude/mcp_servers.json', label: '
|
|
64
|
-
{ path: '.cursor/mcp.json', label: '
|
|
65
|
-
{ path: '.config/windsurf/mcp.json', label: '
|
|
82
|
+
{ path: '.claude/mcp_servers.json', label: 'Claude Code (global)' },
|
|
83
|
+
{ path: '.cursor/mcp.json', label: 'Cursor (global)' },
|
|
84
|
+
{ path: '.config/windsurf/mcp.json', label: 'Windsurf (global)' },
|
|
85
|
+
{ path: '.vscode/globalStorage/saoudrizwan.claude-dev/mcp_servers.json', label: 'Cline (global)' },
|
|
66
86
|
];
|
|
67
87
|
const PROJECT_MCP_FILES = ['mcp.json', '.mcp.json', '.mcp/config.json'];
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
88
|
+
// High-risk MCP capability keywords
|
|
89
|
+
const HIGH_RISK_CAPABILITIES = ['execute', 'shell', 'bash', 'terminal', 'run', 'eval'];
|
|
90
|
+
const MEDIUM_RISK_CAPABILITIES = ['filesystem', 'file', 'write', 'database', 'db', 'sql', 'network', 'http', 'fetch'];
|
|
91
|
+
const AI_CONFIG_PATTERNS = [
|
|
92
|
+
{ files: ['.cursorrules', '.cursor/config.json', '.cursor/rules'], tool: 'Cursor' },
|
|
93
|
+
{ files: ['.claude/settings.json', '.claude/settings.local.json', 'CLAUDE.md'], tool: 'Claude Code' },
|
|
94
|
+
{ files: ['.github/copilot-instructions.md', '.copilot'], tool: 'GitHub Copilot' },
|
|
95
|
+
{ files: ['.windsurfrules', '.windsurf/config.json'], tool: 'Windsurf' },
|
|
96
|
+
{ files: ['.aider.conf.yml', '.aiderignore'], tool: 'Aider' },
|
|
97
|
+
{ files: ['.continue/config.json', '.continuerules'], tool: 'Continue' },
|
|
98
|
+
// SOUL.md is a governance file, not a risk config -- detected by scanIdentity() instead
|
|
99
|
+
{ files: ['arp.config.yml', 'arp.config.yaml', '.opena2a/arp.config.yml'], tool: 'Agent Runtime Protection' },
|
|
100
|
+
{ files: ['langchain.config.js', 'langchain.config.ts'], tool: 'LangChain' },
|
|
101
|
+
{ files: ['.env.ai', 'ai.config.json', 'ai.config.yml'], tool: 'AI Framework' },
|
|
102
|
+
];
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Process scanning
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
72
106
|
function scanProcesses(psOutput) {
|
|
73
107
|
let output;
|
|
74
108
|
if (psOutput !== undefined) {
|
|
@@ -89,10 +123,8 @@ function scanProcesses(psOutput) {
|
|
|
89
123
|
for (const agent of AGENT_PATTERNS) {
|
|
90
124
|
if (seen.has(agent.name))
|
|
91
125
|
continue;
|
|
92
|
-
|
|
93
|
-
if (!matches)
|
|
126
|
+
if (!agent.patterns.some((p) => p.test(line)))
|
|
94
127
|
continue;
|
|
95
|
-
// Extract PID from ps aux output (second column)
|
|
96
128
|
const parts = line.trim().split(/\s+/);
|
|
97
129
|
const pid = parseInt(parts[1], 10);
|
|
98
130
|
if (isNaN(pid))
|
|
@@ -100,25 +132,26 @@ function scanProcesses(psOutput) {
|
|
|
100
132
|
agents.push({
|
|
101
133
|
name: agent.name,
|
|
102
134
|
pid,
|
|
135
|
+
category: agent.category,
|
|
103
136
|
identityStatus: 'no identity',
|
|
104
137
|
governanceStatus: 'no governance',
|
|
138
|
+
risk: agent.category === 'local-llm' ? 'medium' : 'high',
|
|
105
139
|
});
|
|
106
140
|
seen.add(agent.name);
|
|
107
141
|
}
|
|
108
142
|
}
|
|
109
143
|
return agents;
|
|
110
144
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// MCP config parsing
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
114
148
|
function parseMcpConfig(filePath, label) {
|
|
115
149
|
try {
|
|
116
150
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
117
151
|
const config = JSON.parse(content);
|
|
118
152
|
const servers = [];
|
|
119
|
-
// MCP config can be { "mcpServers": { ... } } or { "servers": { ... } } or flat object
|
|
120
153
|
const serversObj = config.mcpServers ?? config.servers ?? config;
|
|
121
|
-
if (typeof serversObj !== 'object' || serversObj === null)
|
|
154
|
+
if (typeof serversObj !== 'object' || serversObj === null || Array.isArray(serversObj))
|
|
122
155
|
return [];
|
|
123
156
|
for (const [name, entry] of Object.entries(serversObj)) {
|
|
124
157
|
if (typeof entry !== 'object' || entry === null)
|
|
@@ -131,12 +164,10 @@ function parseMcpConfig(filePath, label) {
|
|
|
131
164
|
transport = 'sse';
|
|
132
165
|
if (e.transport === 'stdio')
|
|
133
166
|
transport = 'stdio';
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
verified: false,
|
|
139
|
-
});
|
|
167
|
+
// Extract capabilities from server name and config
|
|
168
|
+
const capabilities = inferMcpCapabilities(name, e);
|
|
169
|
+
const risk = classifyMcpRisk(name, capabilities, transport);
|
|
170
|
+
servers.push({ name, transport, source: label, verified: false, capabilities, risk });
|
|
140
171
|
}
|
|
141
172
|
return servers;
|
|
142
173
|
}
|
|
@@ -144,71 +175,197 @@ function parseMcpConfig(filePath, label) {
|
|
|
144
175
|
return [];
|
|
145
176
|
}
|
|
146
177
|
}
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
178
|
+
/** Capability ID to plain-language description map. */
|
|
179
|
+
const CAPABILITY_DESCRIPTIONS = {
|
|
180
|
+
'filesystem': 'Can read and write files on your machine',
|
|
181
|
+
'shell-access': 'Can run any command on your computer',
|
|
182
|
+
'database': 'Can read and modify your database',
|
|
183
|
+
'network': 'Can make requests to external services',
|
|
184
|
+
'browser': 'Can control a web browser and visit pages',
|
|
185
|
+
'source-control': 'Can read and push code to your repositories',
|
|
186
|
+
'messaging': 'Can send messages on your behalf',
|
|
187
|
+
'payments': 'Can access payment and billing systems',
|
|
188
|
+
'cloud-services': 'Can access your cloud infrastructure',
|
|
189
|
+
'unknown': 'Capabilities not determined',
|
|
190
|
+
};
|
|
191
|
+
function capabilityDescription(cap) {
|
|
192
|
+
return CAPABILITY_DESCRIPTIONS[cap] ?? cap;
|
|
193
|
+
}
|
|
194
|
+
function inferMcpCapabilities(name, config) {
|
|
195
|
+
const caps = [];
|
|
196
|
+
const nameLower = name.toLowerCase();
|
|
197
|
+
const args = Array.isArray(config.args) ? config.args.map(String) : [];
|
|
198
|
+
const command = typeof config.command === 'string' ? config.command : '';
|
|
199
|
+
const combined = `${nameLower} ${command} ${args.join(' ')}`.toLowerCase();
|
|
200
|
+
if (/filesys|file|fs\b/.test(combined))
|
|
201
|
+
caps.push('filesystem');
|
|
202
|
+
if (/shell|bash|terminal|exec/.test(combined))
|
|
203
|
+
caps.push('shell-access');
|
|
204
|
+
if (/database|db|sql|postgres|mysql|sqlite/.test(combined))
|
|
205
|
+
caps.push('database');
|
|
206
|
+
if (/network|http|fetch|curl|api/.test(combined))
|
|
207
|
+
caps.push('network');
|
|
208
|
+
if (/browser|playwright|puppeteer|selenium/.test(combined))
|
|
209
|
+
caps.push('browser');
|
|
210
|
+
if (/git\b|github|gitlab/.test(combined))
|
|
211
|
+
caps.push('source-control');
|
|
212
|
+
if (/slack|email|discord|teams/.test(combined))
|
|
213
|
+
caps.push('messaging');
|
|
214
|
+
if (/stripe|payment|billing/.test(combined))
|
|
215
|
+
caps.push('payments');
|
|
216
|
+
if (/supabase|firebase|cloud/.test(combined))
|
|
217
|
+
caps.push('cloud-services');
|
|
218
|
+
if (caps.length === 0)
|
|
219
|
+
caps.push('unknown');
|
|
220
|
+
return caps;
|
|
221
|
+
}
|
|
222
|
+
function classifyMcpRisk(name, capabilities, transport) {
|
|
223
|
+
const nameLower = name.toLowerCase();
|
|
224
|
+
// Shell/execute access is always critical
|
|
225
|
+
if (capabilities.includes('shell-access'))
|
|
226
|
+
return 'critical';
|
|
227
|
+
// Remote SSE servers with sensitive capabilities
|
|
228
|
+
if (transport === 'sse' && (capabilities.includes('database') || capabilities.includes('payments'))) {
|
|
229
|
+
return 'critical';
|
|
230
|
+
}
|
|
231
|
+
// Database or payment access
|
|
232
|
+
if (capabilities.includes('database') || capabilities.includes('payments'))
|
|
233
|
+
return 'high';
|
|
234
|
+
// Network or filesystem access
|
|
235
|
+
if (capabilities.includes('network') || capabilities.includes('filesystem'))
|
|
236
|
+
return 'medium';
|
|
237
|
+
// Known benign patterns
|
|
238
|
+
if (/\b(context7|greptile|serena)\b/.test(nameLower))
|
|
239
|
+
return 'low';
|
|
240
|
+
return 'medium';
|
|
241
|
+
}
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Claude plugin MCP scanning
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
function scanClaudePluginMcpServers() {
|
|
246
|
+
const home = os.homedir();
|
|
247
|
+
const servers = [];
|
|
248
|
+
const pluginsBase = path.join(home, '.claude', 'plugins', 'marketplaces');
|
|
249
|
+
try {
|
|
250
|
+
const marketplaces = fs.readdirSync(pluginsBase, { withFileTypes: true });
|
|
251
|
+
for (const marketplace of marketplaces) {
|
|
252
|
+
if (!marketplace.isDirectory())
|
|
253
|
+
continue;
|
|
254
|
+
for (const subdir of ['external_plugins', 'plugins']) {
|
|
255
|
+
const dir = path.join(pluginsBase, marketplace.name, subdir);
|
|
256
|
+
try {
|
|
257
|
+
const plugins = fs.readdirSync(dir, { withFileTypes: true });
|
|
258
|
+
for (const plugin of plugins) {
|
|
259
|
+
if (!plugin.isDirectory())
|
|
260
|
+
continue;
|
|
261
|
+
const mcpPath = path.join(dir, plugin.name, '.mcp.json');
|
|
262
|
+
servers.push(...parseMcpConfig(mcpPath, `Claude plugin: ${plugin.name}`));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch { /* directory may not exist */ }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch { /* plugins directory may not exist */ }
|
|
270
|
+
return servers;
|
|
271
|
+
}
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// MCP server scanning (all platforms)
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
150
275
|
function scanMcpServers(targetDir) {
|
|
151
276
|
const home = os.homedir();
|
|
152
277
|
const servers = [];
|
|
153
|
-
// Home-directory config locations
|
|
154
278
|
for (const loc of MCP_CONFIG_LOCATIONS) {
|
|
155
|
-
|
|
156
|
-
servers.push(...parseMcpConfig(fullPath, loc.label));
|
|
279
|
+
servers.push(...parseMcpConfig(path.join(home, loc.path), loc.label));
|
|
157
280
|
}
|
|
158
|
-
|
|
281
|
+
servers.push(...scanClaudePluginMcpServers());
|
|
282
|
+
const claudeProjectMcp = path.join(home, '.claude', '.mcp.json');
|
|
283
|
+
servers.push(...parseMcpConfig(claudeProjectMcp, 'Claude Code (project)'));
|
|
159
284
|
const vscodeExtDir = path.join(home, '.vscode', 'extensions');
|
|
160
285
|
try {
|
|
161
286
|
const entries = fs.readdirSync(vscodeExtDir, { withFileTypes: true });
|
|
162
287
|
for (const entry of entries) {
|
|
163
288
|
if (!entry.isDirectory())
|
|
164
289
|
continue;
|
|
165
|
-
|
|
166
|
-
servers.push(...parseMcpConfig(mcpPath, `~/.vscode/extensions/${entry.name}/mcp.json`));
|
|
290
|
+
servers.push(...parseMcpConfig(path.join(vscodeExtDir, entry.name, 'mcp.json'), `VS Code: ${entry.name}`));
|
|
167
291
|
}
|
|
168
292
|
}
|
|
169
|
-
catch {
|
|
170
|
-
// Directory may not exist
|
|
171
|
-
}
|
|
172
|
-
// Project-local MCP config files
|
|
293
|
+
catch { /* directory may not exist */ }
|
|
173
294
|
for (const filename of PROJECT_MCP_FILES) {
|
|
174
|
-
|
|
175
|
-
servers.push(...parseMcpConfig(fullPath, `${filename} (project)`));
|
|
295
|
+
servers.push(...parseMcpConfig(path.join(targetDir, filename), `${filename} (project)`));
|
|
176
296
|
}
|
|
177
297
|
return servers;
|
|
178
298
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// AI config file discovery
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
function scanAiConfigs(targetDir) {
|
|
303
|
+
const configs = [];
|
|
304
|
+
for (const pattern of AI_CONFIG_PATTERNS) {
|
|
305
|
+
for (const file of pattern.files) {
|
|
306
|
+
const fullPath = path.join(targetDir, file);
|
|
307
|
+
if (!fs.existsSync(fullPath))
|
|
308
|
+
continue;
|
|
309
|
+
let details = `${pattern.tool} configuration`;
|
|
310
|
+
let risk = 'low';
|
|
311
|
+
// Check if it contains credential references
|
|
312
|
+
try {
|
|
313
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
314
|
+
const hasApiKey = /(?:api[_-]?key|secret|token|password)\s*[:=]\s*["']?[a-zA-Z0-9_-]{20,}/i.test(content);
|
|
315
|
+
const hasPermissions = /(?:allow|permit|grant|unrestricted|all\s+bash)/i.test(content);
|
|
316
|
+
if (hasApiKey) {
|
|
317
|
+
risk = 'critical';
|
|
318
|
+
details = `${pattern.tool} config contains credential references`;
|
|
319
|
+
}
|
|
320
|
+
else if (hasPermissions) {
|
|
321
|
+
risk = 'high';
|
|
322
|
+
details = `${pattern.tool} config grants broad permissions`;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
risk = 'low';
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch { /* file unreadable */ }
|
|
329
|
+
configs.push({ file, tool: pattern.tool, risk, details });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return configs;
|
|
333
|
+
}
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Identity & governance scanning
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
182
337
|
function scanIdentity(targetDir) {
|
|
183
338
|
let aimIdentities = 0;
|
|
184
339
|
let mcpIdentities = 0;
|
|
185
340
|
let soulFiles = 0;
|
|
186
341
|
let capabilityPolicies = 0;
|
|
187
|
-
// Check for .opena2a/ directory in target dir only (project-scoped)
|
|
188
342
|
const opena2aDir = path.join(targetDir, '.opena2a');
|
|
189
343
|
if (fs.existsSync(opena2aDir)) {
|
|
344
|
+
// Check for actual AIM identity file in project
|
|
345
|
+
const identityFile = path.join(opena2aDir, 'aim', 'identity.json');
|
|
346
|
+
if (fs.existsSync(identityFile)) {
|
|
347
|
+
aimIdentities++;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Also check global identity location (~/.opena2a/aim-core/identity.json)
|
|
351
|
+
// This is where `opena2a identity create` writes by default
|
|
352
|
+
const globalIdentity = path.join(os.homedir(), '.opena2a', 'aim-core', 'identity.json');
|
|
353
|
+
if (aimIdentities === 0 && fs.existsSync(globalIdentity)) {
|
|
190
354
|
aimIdentities++;
|
|
191
|
-
|
|
355
|
+
}
|
|
356
|
+
if (fs.existsSync(opena2aDir)) {
|
|
192
357
|
const mcpIdDir = path.join(opena2aDir, 'mcp-identities');
|
|
193
358
|
if (fs.existsSync(mcpIdDir)) {
|
|
194
359
|
try {
|
|
195
|
-
|
|
196
|
-
mcpIdentities = files.length;
|
|
360
|
+
mcpIdentities = fs.readdirSync(mcpIdDir).filter((f) => f.endsWith('.json')).length;
|
|
197
361
|
}
|
|
198
362
|
catch { /* ignore */ }
|
|
199
363
|
}
|
|
200
364
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
path.join(targetDir, 'SOUL.md'),
|
|
204
|
-
path.join(targetDir, '.opena2a', 'SOUL.md'),
|
|
205
|
-
];
|
|
206
|
-
for (const p of soulPaths) {
|
|
207
|
-
if (fs.existsSync(p)) {
|
|
365
|
+
for (const p of [path.join(targetDir, 'SOUL.md'), path.join(targetDir, '.opena2a', 'SOUL.md')]) {
|
|
366
|
+
if (fs.existsSync(p))
|
|
208
367
|
soulFiles++;
|
|
209
|
-
}
|
|
210
368
|
}
|
|
211
|
-
// Check for capability policy files
|
|
212
369
|
const policyPaths = [
|
|
213
370
|
path.join(targetDir, '.opena2a', 'policy.yml'),
|
|
214
371
|
path.join(targetDir, '.opena2a', 'policy.yaml'),
|
|
@@ -217,85 +374,482 @@ function scanIdentity(targetDir) {
|
|
|
217
374
|
path.join(targetDir, 'opena2a.policy.yaml'),
|
|
218
375
|
];
|
|
219
376
|
for (const p of policyPaths) {
|
|
220
|
-
if (fs.existsSync(p))
|
|
377
|
+
if (fs.existsSync(p))
|
|
221
378
|
capabilityPolicies++;
|
|
222
|
-
}
|
|
223
379
|
}
|
|
224
380
|
return { aimIdentities, mcpIdentities, totalAgents: 0, soulFiles, capabilityPolicies };
|
|
225
381
|
}
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Risk scoring
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
226
385
|
/**
|
|
227
|
-
*
|
|
386
|
+
* Calculate governance score (0-100, where 100 = fully governed).
|
|
387
|
+
*
|
|
388
|
+
* Internally computes deductions for gaps, then inverts:
|
|
389
|
+
* governanceScore = 100 - deductions
|
|
390
|
+
*
|
|
391
|
+
* This way users see 100 as the goal and the score goes UP as they fix things.
|
|
228
392
|
*/
|
|
393
|
+
function calculateGovernanceScore(result) {
|
|
394
|
+
let deductions = 0;
|
|
395
|
+
// Ungoverned agents: 15 points each
|
|
396
|
+
for (const agent of result.agents) {
|
|
397
|
+
if (agent.governanceStatus === 'no governance')
|
|
398
|
+
deductions += 15;
|
|
399
|
+
if (agent.identityStatus === 'no identity')
|
|
400
|
+
deductions += 10;
|
|
401
|
+
}
|
|
402
|
+
// Unverified MCP servers -- only project-local servers affect the score.
|
|
403
|
+
// Global/machine-wide servers (Claude plugins, ~/.cursor, etc.) are shown
|
|
404
|
+
// for awareness but don't penalize the project governance score because
|
|
405
|
+
// the user cannot verify them at the project level.
|
|
406
|
+
for (const server of result.mcpServers) {
|
|
407
|
+
if (server.verified)
|
|
408
|
+
continue;
|
|
409
|
+
const isProjectLocal = server.source.includes('(project)');
|
|
410
|
+
if (!isProjectLocal)
|
|
411
|
+
continue;
|
|
412
|
+
if (server.risk === 'critical')
|
|
413
|
+
deductions += 20;
|
|
414
|
+
else if (server.risk === 'high')
|
|
415
|
+
deductions += 12;
|
|
416
|
+
else if (server.risk === 'medium')
|
|
417
|
+
deductions += 5;
|
|
418
|
+
else
|
|
419
|
+
deductions += 2;
|
|
420
|
+
}
|
|
421
|
+
// AI config risk
|
|
422
|
+
for (const config of result.aiConfigs) {
|
|
423
|
+
if (config.risk === 'critical')
|
|
424
|
+
deductions += 25;
|
|
425
|
+
else if (config.risk === 'high')
|
|
426
|
+
deductions += 15;
|
|
427
|
+
else if (config.risk === 'medium')
|
|
428
|
+
deductions += 5;
|
|
429
|
+
}
|
|
430
|
+
// Governance gap: no AIM identity is a multiplier
|
|
431
|
+
if (result.identity.aimIdentities === 0 && result.agents.length > 0)
|
|
432
|
+
deductions += 20;
|
|
433
|
+
if (result.identity.soulFiles === 0 && result.agents.length > 0)
|
|
434
|
+
deductions += 10;
|
|
435
|
+
// Cap deductions at 100, round
|
|
436
|
+
deductions = Math.min(Math.round(deductions), 100);
|
|
437
|
+
return { governanceScore: 100 - deductions, deductions };
|
|
438
|
+
}
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Finding generation
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
function generateFindings(result) {
|
|
443
|
+
const findings = [];
|
|
444
|
+
// No AIM identity -- recommend this first since identity is the foundation
|
|
445
|
+
const hasIdentity = result.identity.aimIdentities > 0;
|
|
446
|
+
if (!hasIdentity && result.agents.length > 0) {
|
|
447
|
+
findings.push({
|
|
448
|
+
severity: 'high',
|
|
449
|
+
category: 'identity',
|
|
450
|
+
title: 'No agent identity for this project',
|
|
451
|
+
detail: `${result.agents.length} AI tool${result.agents.length !== 1 ? 's' : ''} detected but no identity is registered`,
|
|
452
|
+
whyItMatters: 'An agent identity is a cryptographic key pair that lets you track which agent '
|
|
453
|
+
+ 'did what in this project. Without one, agent actions cannot be attributed, verified, or '
|
|
454
|
+
+ 'audited. This is the first step to managing AI tools in your project.',
|
|
455
|
+
remediation: 'opena2a identity create --name my-project',
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
// Ungoverned agents (consolidates governance + SOUL.md into one finding)
|
|
459
|
+
const ungoverned = result.agents.filter((a) => a.governanceStatus === 'no governance');
|
|
460
|
+
if (ungoverned.length > 0) {
|
|
461
|
+
const noSoul = result.identity.soulFiles === 0;
|
|
462
|
+
const detail = ungoverned.map((a) => a.name).join(', ')
|
|
463
|
+
+ (noSoul ? ' -- no SOUL.md governance file found' : '');
|
|
464
|
+
findings.push({
|
|
465
|
+
severity: 'high',
|
|
466
|
+
category: 'governance',
|
|
467
|
+
title: `${ungoverned.length} AI agent${ungoverned.length !== 1 ? 's' : ''} running without governance`,
|
|
468
|
+
detail,
|
|
469
|
+
whyItMatters: 'These agents can take actions in your project but have no rules defining what they '
|
|
470
|
+
+ 'should or should not do. A SOUL.md file sets behavioral boundaries — what agents can and '
|
|
471
|
+
+ 'cannot do, and what requires human approval. Without one, agents rely entirely on their defaults.',
|
|
472
|
+
remediation: 'opena2a harden-soul',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
// Project-local MCP servers with sensitive access (actionable -- affects score)
|
|
476
|
+
const projectCriticalMcp = result.mcpServers.filter((s) => s.risk === 'critical' && !s.verified && s.source.includes('(project)'));
|
|
477
|
+
if (projectCriticalMcp.length > 0) {
|
|
478
|
+
const details = projectCriticalMcp.map((s) => {
|
|
479
|
+
const caps = s.capabilities.filter((c) => c !== 'unknown');
|
|
480
|
+
const humanCaps = caps.map((c) => capabilityDescription(c).toLowerCase()).join(', ');
|
|
481
|
+
return `${s.name}: ${humanCaps}`;
|
|
482
|
+
});
|
|
483
|
+
findings.push({
|
|
484
|
+
severity: 'critical',
|
|
485
|
+
category: 'mcp',
|
|
486
|
+
title: `${projectCriticalMcp.length} project MCP server${projectCriticalMcp.length !== 1 ? 's' : ''} with sensitive access`,
|
|
487
|
+
detail: details.join('; '),
|
|
488
|
+
whyItMatters: 'These MCP servers are configured in your project and grant access to sensitive '
|
|
489
|
+
+ 'operations like running commands, accessing databases, or processing payments. '
|
|
490
|
+
+ 'Verifying them confirms they are the servers you intended to install.',
|
|
491
|
+
remediation: 'opena2a mcp audit',
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
// Project-local unverified MCP servers
|
|
495
|
+
const projectUnverified = result.mcpServers.filter((s) => !s.verified && s.source.includes('(project)'));
|
|
496
|
+
if (projectUnverified.length > 0 && projectCriticalMcp.length === 0) {
|
|
497
|
+
findings.push({
|
|
498
|
+
severity: 'medium',
|
|
499
|
+
category: 'mcp',
|
|
500
|
+
title: `${projectUnverified.length} project MCP server${projectUnverified.length !== 1 ? 's' : ''} without verified identity`,
|
|
501
|
+
detail: 'These servers are configured in your project but have not been signed.',
|
|
502
|
+
whyItMatters: 'Unverified servers could be modified or replaced without detection. '
|
|
503
|
+
+ 'Signing creates a tamper-evident record of exactly which server version is in use.',
|
|
504
|
+
remediation: 'opena2a mcp audit',
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
// Config files with credential references
|
|
508
|
+
const criticalConfigs = result.aiConfigs.filter((c) => c.risk === 'critical');
|
|
509
|
+
if (criticalConfigs.length > 0) {
|
|
510
|
+
findings.push({
|
|
511
|
+
severity: 'critical',
|
|
512
|
+
category: 'config',
|
|
513
|
+
title: 'AI config files contain credential references',
|
|
514
|
+
detail: criticalConfigs.map((c) => c.file).join(', '),
|
|
515
|
+
whyItMatters: 'API keys or tokens appear to be stored directly in these configuration files. '
|
|
516
|
+
+ 'Anyone with access to the file (or the repository) can see and use these credentials. '
|
|
517
|
+
+ 'Moving them to environment variables limits exposure.',
|
|
518
|
+
remediation: 'opena2a protect',
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
// Broad permission grants
|
|
522
|
+
const highConfigs = result.aiConfigs.filter((c) => c.risk === 'high');
|
|
523
|
+
if (highConfigs.length > 0) {
|
|
524
|
+
findings.push({
|
|
525
|
+
severity: 'high',
|
|
526
|
+
category: 'config',
|
|
527
|
+
title: 'AI config files grant broad permissions',
|
|
528
|
+
detail: highConfigs.map((c) => c.file).join(', '),
|
|
529
|
+
whyItMatters: 'These configs allow AI agents to perform a wide range of actions without '
|
|
530
|
+
+ 'restrictions. Broad permissions increase the surface area if an agent behaves '
|
|
531
|
+
+ 'unexpectedly or if the config is modified by a third party.',
|
|
532
|
+
remediation: 'opena2a scan-soul',
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
// No SOUL governance (only if agents ARE governed but SOUL is missing --
|
|
536
|
+
// if ungoverned, the governance finding above already covers SOUL.md)
|
|
537
|
+
if (result.identity.soulFiles === 0 && result.agents.length > 0 && ungoverned.length === 0) {
|
|
538
|
+
findings.push({
|
|
539
|
+
severity: 'medium',
|
|
540
|
+
category: 'governance',
|
|
541
|
+
title: 'No SOUL.md governance file in this project',
|
|
542
|
+
detail: 'Agents are governed by capability policies but have no SOUL.md behavioral boundaries.',
|
|
543
|
+
whyItMatters: 'A SOUL.md file defines what an agent should and should not do beyond capability '
|
|
544
|
+
+ 'restrictions — handling errors, sensitive data, and when to ask for human approval.',
|
|
545
|
+
remediation: 'opena2a harden-soul',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
// Sort by severity
|
|
549
|
+
const order = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
550
|
+
findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
551
|
+
return findings;
|
|
552
|
+
}
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// CSV export -- asset inventory for enterprise tools (ServiceNow, CMDB, etc.)
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
function csvEscape(value) {
|
|
557
|
+
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
|
558
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
559
|
+
}
|
|
560
|
+
return value;
|
|
561
|
+
}
|
|
562
|
+
function generateAssetCsv(result) {
|
|
563
|
+
const rows = [];
|
|
564
|
+
const hostname = os.hostname();
|
|
565
|
+
const username = os.userInfo().username;
|
|
566
|
+
const scanTime = result.scanTimestamp;
|
|
567
|
+
const scanDir = result.scanDirectory;
|
|
568
|
+
// Header -- columns designed for enterprise CMDB/ServiceNow import
|
|
569
|
+
rows.push('Hostname,Username,Scan Directory,Scan Timestamp,Asset Type,Name,Installed From,Transport,Capabilities,Risk');
|
|
570
|
+
const deviceCols = [csvEscape(hostname), csvEscape(username), csvEscape(scanDir), scanTime].join(',');
|
|
571
|
+
// AI Agents
|
|
572
|
+
for (const agent of result.agents) {
|
|
573
|
+
rows.push([
|
|
574
|
+
deviceCols,
|
|
575
|
+
'AI Agent',
|
|
576
|
+
csvEscape(agent.name),
|
|
577
|
+
'Running process',
|
|
578
|
+
'',
|
|
579
|
+
agent.category,
|
|
580
|
+
agent.risk,
|
|
581
|
+
].join(','));
|
|
582
|
+
}
|
|
583
|
+
// MCP Servers
|
|
584
|
+
for (const server of result.mcpServers) {
|
|
585
|
+
const caps = server.capabilities.filter((c) => c !== 'unknown');
|
|
586
|
+
const isProjectLocal = server.source.includes('(project)');
|
|
587
|
+
const scope = isProjectLocal ? 'This project' : 'User machine';
|
|
588
|
+
rows.push([
|
|
589
|
+
deviceCols,
|
|
590
|
+
'MCP Server',
|
|
591
|
+
csvEscape(server.name),
|
|
592
|
+
scope,
|
|
593
|
+
server.transport,
|
|
594
|
+
csvEscape(caps.map((c) => capabilityDescription(c)).join('; ')),
|
|
595
|
+
server.risk,
|
|
596
|
+
].join(','));
|
|
597
|
+
}
|
|
598
|
+
// AI Config Files
|
|
599
|
+
for (const config of result.aiConfigs) {
|
|
600
|
+
rows.push([
|
|
601
|
+
deviceCols,
|
|
602
|
+
'AI Config',
|
|
603
|
+
csvEscape(config.file),
|
|
604
|
+
csvEscape(config.tool),
|
|
605
|
+
'',
|
|
606
|
+
csvEscape(config.details),
|
|
607
|
+
config.risk,
|
|
608
|
+
].join(','));
|
|
609
|
+
}
|
|
610
|
+
return rows.join('\n') + '\n';
|
|
611
|
+
}
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
// Text formatting
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
function riskColor(level) {
|
|
616
|
+
switch (level) {
|
|
617
|
+
case 'critical': return colors_js_1.red;
|
|
618
|
+
case 'high': return (t) => `\x1b[38;5;208m${t}\x1b[39m`; // orange
|
|
619
|
+
case 'medium': return colors_js_1.yellow;
|
|
620
|
+
case 'low': return colors_js_1.green;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function riskLabel(level) {
|
|
624
|
+
return riskColor(level)(level.toUpperCase());
|
|
625
|
+
}
|
|
626
|
+
function buildSummaryLine(result) {
|
|
627
|
+
const { summary } = result;
|
|
628
|
+
const parts = [];
|
|
629
|
+
if (summary.totalAgents === 0 && summary.mcpServers === 0 && summary.aiConfigs === 0) {
|
|
630
|
+
return (0, colors_js_1.dim)('No AI agents, MCP servers, or AI configs detected.');
|
|
631
|
+
}
|
|
632
|
+
if (summary.totalAgents > 0) {
|
|
633
|
+
parts.push(`${(0, colors_js_1.bold)(String(summary.totalAgents))} AI agent${summary.totalAgents !== 1 ? 's' : ''}`);
|
|
634
|
+
}
|
|
635
|
+
if (summary.mcpServers > 0) {
|
|
636
|
+
parts.push(`${(0, colors_js_1.bold)(String(summary.mcpServers))} MCP server${summary.mcpServers !== 1 ? 's' : ''}`);
|
|
637
|
+
}
|
|
638
|
+
if (summary.aiConfigs > 0) {
|
|
639
|
+
parts.push(`${(0, colors_js_1.bold)(String(summary.aiConfigs))} AI config${summary.aiConfigs !== 1 ? 's' : ''}`);
|
|
640
|
+
}
|
|
641
|
+
if (summary.localLlms > 0) {
|
|
642
|
+
parts.push(`${(0, colors_js_1.bold)(String(summary.localLlms))} local LLM${summary.localLlms !== 1 ? 's' : ''}`);
|
|
643
|
+
}
|
|
644
|
+
return parts.join(' | ');
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Build the governance score summary with recovery framing.
|
|
648
|
+
* 100 = fully governed (the goal), 0 = nothing in place.
|
|
649
|
+
*
|
|
650
|
+
* "Governance: 35/100 -> 82/100 by addressing 3 findings"
|
|
651
|
+
*/
|
|
652
|
+
function buildGovernanceSummary(result) {
|
|
653
|
+
const { summary } = result;
|
|
654
|
+
const score = summary.governanceScore;
|
|
655
|
+
if (score === 100) {
|
|
656
|
+
return (0, colors_js_1.green)('Governance: 100/100 -- fully governed');
|
|
657
|
+
}
|
|
658
|
+
const scoreColor = score >= 70 ? colors_js_1.green : score >= 40 ? colors_js_1.yellow : colors_js_1.red;
|
|
659
|
+
const projected = Math.min(100, score + summary.recoverablePoints);
|
|
660
|
+
let line = `Governance: ${scoreColor((0, colors_js_1.bold)(String(score)))}/100`;
|
|
661
|
+
if (result.findings.length > 0 && projected > score) {
|
|
662
|
+
line += ` -> ${(0, colors_js_1.green)((0, colors_js_1.bold)(String(projected)))}/100 by addressing ${result.findings.length} finding${result.findings.length !== 1 ? 's' : ''}`;
|
|
663
|
+
}
|
|
664
|
+
return line;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Build a plain-language explanation of what was found, for people
|
|
668
|
+
* who do not think in security terminology.
|
|
669
|
+
*/
|
|
670
|
+
function buildWhatThisMeans(result) {
|
|
671
|
+
const lines = [];
|
|
672
|
+
const { summary } = result;
|
|
673
|
+
if (summary.totalAgents === 0 && summary.mcpServers === 0)
|
|
674
|
+
return lines;
|
|
675
|
+
lines.push((0, colors_js_1.bold)('What This Means'));
|
|
676
|
+
// Explain agent detection
|
|
677
|
+
if (summary.totalAgents > 0) {
|
|
678
|
+
const governed = summary.totalAgents - summary.ungoverned;
|
|
679
|
+
if (summary.ungoverned === 0) {
|
|
680
|
+
lines.push(` Your ${summary.totalAgents === 1 ? 'AI agent has' : 'AI agents have'} `
|
|
681
|
+
+ `governance in place. Actions are bounded by the rules you defined.`);
|
|
682
|
+
}
|
|
683
|
+
else if (governed > 0) {
|
|
684
|
+
lines.push(` ${summary.totalAgents} AI tool${summary.totalAgents !== 1 ? 's are' : ' is'} running on this machine. `
|
|
685
|
+
+ `${governed} ${governed === 1 ? 'has' : 'have'} governance rules, `
|
|
686
|
+
+ `${summary.ungoverned} ${summary.ungoverned === 1 ? 'does' : 'do'} not.`);
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
lines.push(` ${summary.totalAgents} AI tool${summary.totalAgents !== 1 ? 's are' : ' is'} running `
|
|
690
|
+
+ `without governance. This means there are no documented rules limiting what `
|
|
691
|
+
+ `${summary.totalAgents === 1 ? 'it' : 'they'} can do in this project.`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Explain MCP servers
|
|
695
|
+
if (summary.mcpServers > 0) {
|
|
696
|
+
const verified = summary.mcpServers - summary.unverifiedServers;
|
|
697
|
+
lines.push(` ${summary.mcpServers} MCP server${summary.mcpServers !== 1 ? 's give' : ' gives'} your AI agents `
|
|
698
|
+
+ `additional capabilities (file access, database queries, API calls, etc.).`);
|
|
699
|
+
if (summary.unverifiedServers > 0 && verified > 0) {
|
|
700
|
+
lines.push(` ${verified} ${verified === 1 ? 'has' : 'have'} verified identities, `
|
|
701
|
+
+ `${summary.unverifiedServers} ${summary.unverifiedServers === 1 ? 'does' : 'do'} not.`);
|
|
702
|
+
}
|
|
703
|
+
else if (summary.unverifiedServers === summary.mcpServers) {
|
|
704
|
+
lines.push(` None have verified identities, so there is no tamper-evident record of `
|
|
705
|
+
+ `which server version is installed.`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
lines.push('');
|
|
709
|
+
return lines;
|
|
710
|
+
}
|
|
229
711
|
function formatText(result, verbose, targetDir) {
|
|
230
712
|
const lines = [];
|
|
713
|
+
// Header with machine context
|
|
231
714
|
lines.push((0, colors_js_1.bold)('Shadow AI Agent Audit'));
|
|
232
|
-
lines.push(
|
|
715
|
+
lines.push((0, colors_js_1.dim)(`${os.hostname()} | ${os.userInfo().username} | ${targetDir}`));
|
|
716
|
+
lines.push((0, colors_js_1.dim)(result.scanTimestamp.replace('T', ' ').replace(/\.\d+Z$/, ' UTC')));
|
|
717
|
+
lines.push('');
|
|
718
|
+
// Score and summary -- the only thing a Sr. Manager reads
|
|
719
|
+
lines.push(buildGovernanceSummary(result));
|
|
720
|
+
lines.push(buildSummaryLine(result));
|
|
233
721
|
lines.push('');
|
|
234
|
-
//
|
|
722
|
+
// What This Means (plain-language overview for non-security audience)
|
|
723
|
+
lines.push(...buildWhatThisMeans(result));
|
|
724
|
+
// Findings -- the actionable part, front and center
|
|
725
|
+
if (result.findings.length > 0) {
|
|
726
|
+
lines.push((0, colors_js_1.bold)(`Findings (${result.findings.length})`));
|
|
727
|
+
for (const finding of result.findings) {
|
|
728
|
+
lines.push('');
|
|
729
|
+
lines.push(` ${riskLabel(finding.severity)} ${finding.title}`);
|
|
730
|
+
if (finding.detail) {
|
|
731
|
+
lines.push(` ${(0, colors_js_1.dim)(finding.detail)}`);
|
|
732
|
+
}
|
|
733
|
+
lines.push(` ${finding.whyItMatters}`);
|
|
734
|
+
lines.push(` ${(0, colors_js_1.dim)('Fix:')} ${(0, colors_js_1.cyan)(finding.remediation)}`);
|
|
735
|
+
}
|
|
736
|
+
lines.push('');
|
|
737
|
+
}
|
|
738
|
+
// All clear
|
|
739
|
+
if (result.findings.length === 0) {
|
|
740
|
+
lines.push((0, colors_js_1.green)('All detected AI tools have governance in place. No findings.'));
|
|
741
|
+
lines.push('');
|
|
742
|
+
}
|
|
743
|
+
// Running AI Agents -- compact, no PIDs unless verbose
|
|
744
|
+
const assistants = result.agents.filter((a) => a.category === 'ai-assistant');
|
|
745
|
+
const llms = result.agents.filter((a) => a.category === 'local-llm');
|
|
235
746
|
lines.push((0, colors_js_1.bold)('Running AI Agents'));
|
|
236
|
-
if (
|
|
237
|
-
lines.push((0, colors_js_1.dim)(' No AI agents detected
|
|
747
|
+
if (assistants.length === 0 && llms.length === 0) {
|
|
748
|
+
lines.push((0, colors_js_1.dim)(' No AI agents detected'));
|
|
238
749
|
}
|
|
239
750
|
else {
|
|
240
|
-
for (const agent of
|
|
241
|
-
const nameCol = agent.name.padEnd(
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const govStatus = agent.governanceStatus === 'governed'
|
|
247
|
-
? (0, colors_js_1.green)(agent.governanceStatus)
|
|
248
|
-
: (0, colors_js_1.yellow)(agent.governanceStatus);
|
|
249
|
-
lines.push(` ${nameCol}${pidCol}${idStatus} ${govStatus}`);
|
|
751
|
+
for (const agent of [...assistants, ...llms]) {
|
|
752
|
+
const nameCol = agent.name.padEnd(22);
|
|
753
|
+
const idStatus = agent.identityStatus === 'identified' ? (0, colors_js_1.green)('identified') : (0, colors_js_1.yellow)('no identity');
|
|
754
|
+
const govStatus = agent.governanceStatus === 'governed' ? (0, colors_js_1.green)('governed') : (0, colors_js_1.yellow)('ungoverned');
|
|
755
|
+
const pidStr = verbose ? (0, colors_js_1.dim)(` (PID ${agent.pid})`) : '';
|
|
756
|
+
lines.push(` ${nameCol}${idStatus} ${govStatus}${pidStr}`);
|
|
250
757
|
}
|
|
251
758
|
}
|
|
252
759
|
lines.push('');
|
|
253
|
-
//
|
|
760
|
+
// Identity & Governance details are omitted from default output.
|
|
761
|
+
// The governance score and findings already communicate everything actionable.
|
|
762
|
+
// Show only in verbose mode for debugging.
|
|
763
|
+
if (verbose) {
|
|
764
|
+
lines.push((0, colors_js_1.bold)('Identity & Governance'));
|
|
765
|
+
lines.push(` Project identity: ${result.identity.aimIdentities > 0 ? (0, colors_js_1.green)('initialized (.opena2a/)') : (0, colors_js_1.yellow)('not initialized')}`);
|
|
766
|
+
lines.push(` Behavioral rules: ${result.identity.soulFiles === 0 ? (0, colors_js_1.yellow)('none') : (0, colors_js_1.green)(`${result.identity.soulFiles} SOUL.md`)}`);
|
|
767
|
+
if (result.identity.mcpIdentities > 0) {
|
|
768
|
+
lines.push(` MCP identities: ${(0, colors_js_1.green)(`${result.identity.mcpIdentities} server(s) signed`)}`);
|
|
769
|
+
}
|
|
770
|
+
lines.push('');
|
|
771
|
+
}
|
|
772
|
+
// MCP Servers -- show summary in default mode, full list in verbose
|
|
254
773
|
const mcpCount = result.mcpServers.length;
|
|
774
|
+
const projectMcp = result.mcpServers.filter((s) => s.source.includes('(project)'));
|
|
775
|
+
const globalMcp = result.mcpServers.filter((s) => !s.source.includes('(project)'));
|
|
255
776
|
lines.push((0, colors_js_1.bold)(`MCP Servers (${mcpCount} found)`));
|
|
256
777
|
if (mcpCount === 0) {
|
|
257
778
|
lines.push((0, colors_js_1.dim)(' No MCP server configurations found'));
|
|
258
779
|
}
|
|
259
780
|
else {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
781
|
+
// Always show project-local MCP servers with capabilities (these are actionable)
|
|
782
|
+
if (projectMcp.length > 0) {
|
|
783
|
+
lines.push(` ${(0, colors_js_1.bold)('Project-local')} (${projectMcp.length})`);
|
|
784
|
+
const riskOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
785
|
+
projectMcp.sort((a, b) => riskOrder[a.risk] - riskOrder[b.risk]);
|
|
786
|
+
for (const server of projectMcp) {
|
|
787
|
+
const nameCol = server.name.padEnd(20);
|
|
788
|
+
const verifiedStr = server.verified ? (0, colors_js_1.green)(' verified') : '';
|
|
789
|
+
const realCaps = server.capabilities.filter((c) => c !== 'unknown');
|
|
790
|
+
const capsStr = realCaps.length > 0
|
|
791
|
+
? (0, colors_js_1.dim)(` -- ${realCaps.map((c) => capabilityDescription(c).toLowerCase()).join(', ')}`)
|
|
792
|
+
: '';
|
|
793
|
+
lines.push(` ${nameCol}${verifiedStr}${capsStr}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// Show global MCP servers as a compact summary (not individually unless verbose)
|
|
797
|
+
if (globalMcp.length > 0) {
|
|
798
|
+
if (verbose) {
|
|
799
|
+
lines.push(` ${(0, colors_js_1.bold)('Machine-wide')} (${globalMcp.length})`);
|
|
800
|
+
for (const server of globalMcp) {
|
|
801
|
+
const nameCol = server.name.padEnd(20);
|
|
802
|
+
const realCaps = server.capabilities.filter((c) => c !== 'unknown');
|
|
803
|
+
const capsStr = realCaps.length > 0
|
|
804
|
+
? (0, colors_js_1.dim)(` -- ${realCaps.map((c) => capabilityDescription(c).toLowerCase()).join(', ')}`)
|
|
805
|
+
: '';
|
|
806
|
+
lines.push(` ${nameCol}${capsStr}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
// Compact: just show the count and which ones have sensitive access
|
|
811
|
+
const sensitiveCaps = globalMcp.filter((s) => s.capabilities.some((c) => ['shell-access', 'database', 'payments', 'cloud-services'].includes(c)));
|
|
812
|
+
let globalLine = ` ${(0, colors_js_1.dim)(`Machine-wide (${globalMcp.length})`)}`;
|
|
813
|
+
if (sensitiveCaps.length > 0) {
|
|
814
|
+
const names = sensitiveCaps.map((s) => s.name).join(', ');
|
|
815
|
+
globalLine += (0, colors_js_1.dim)(` -- ${sensitiveCaps.length} with sensitive access: ${names}`);
|
|
816
|
+
}
|
|
817
|
+
lines.push(globalLine);
|
|
818
|
+
lines.push((0, colors_js_1.dim)(` Run with --verbose to see full list`));
|
|
819
|
+
}
|
|
268
820
|
}
|
|
269
821
|
}
|
|
270
822
|
lines.push('');
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
823
|
+
// AI Config Files -- only show if there are noteworthy ones
|
|
824
|
+
const noteworthyConfigs = result.aiConfigs.filter((c) => c.risk !== 'low');
|
|
825
|
+
if (result.aiConfigs.length > 0) {
|
|
826
|
+
if (noteworthyConfigs.length > 0 || verbose) {
|
|
827
|
+
lines.push((0, colors_js_1.bold)(`AI Config Files (${result.aiConfigs.length} found)`));
|
|
828
|
+
const configsToShow = verbose ? result.aiConfigs : noteworthyConfigs;
|
|
829
|
+
for (const config of configsToShow) {
|
|
830
|
+
const fileCol = config.file.padEnd(35);
|
|
831
|
+
const toolCol = config.tool;
|
|
832
|
+
lines.push(` ${fileCol}${toolCol}`);
|
|
833
|
+
if (config.risk === 'critical') {
|
|
834
|
+
lines.push(` ${(0, colors_js_1.yellow)('Contains credential references -- these should be in environment variables')}`);
|
|
835
|
+
}
|
|
836
|
+
else if (config.risk === 'high') {
|
|
837
|
+
lines.push(` ${(0, colors_js_1.yellow)('Grants broad permissions to AI agents in this project')}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (!verbose && result.aiConfigs.length > noteworthyConfigs.length) {
|
|
841
|
+
lines.push((0, colors_js_1.dim)(` + ${result.aiConfigs.length - noteworthyConfigs.length} low-risk config(s) -- run with --verbose to see all`));
|
|
842
|
+
}
|
|
843
|
+
lines.push('');
|
|
844
|
+
}
|
|
290
845
|
}
|
|
291
846
|
return lines.join('\n');
|
|
292
847
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
// Main entry point
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
296
851
|
async function detect(options) {
|
|
297
|
-
const dir = options.targetDir ?? process.cwd();
|
|
298
|
-
// Validate directory
|
|
852
|
+
const dir = path.resolve(options.targetDir ?? process.cwd());
|
|
299
853
|
try {
|
|
300
854
|
fs.accessSync(dir, fs.constants.R_OK);
|
|
301
855
|
}
|
|
@@ -307,29 +861,84 @@ async function detect(options) {
|
|
|
307
861
|
const agents = scanProcesses();
|
|
308
862
|
const mcpServers = scanMcpServers(dir);
|
|
309
863
|
const identity = scanIdentity(dir);
|
|
310
|
-
|
|
864
|
+
const aiConfigs = scanAiConfigs(dir);
|
|
311
865
|
identity.totalAgents = agents.length;
|
|
312
|
-
// Enrich agents with identity
|
|
866
|
+
// Enrich agents with identity/governance from project context
|
|
313
867
|
if (identity.aimIdentities > 0) {
|
|
314
|
-
|
|
315
|
-
|
|
868
|
+
for (const agent of agents) {
|
|
869
|
+
agent.identityStatus = 'identified';
|
|
870
|
+
agent.risk = agent.risk === 'high' ? 'medium' : agent.risk;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (identity.soulFiles > 0 || identity.capabilityPolicies > 0) {
|
|
874
|
+
for (const agent of agents) {
|
|
875
|
+
agent.governanceStatus = 'governed';
|
|
876
|
+
agent.risk = 'low';
|
|
877
|
+
}
|
|
316
878
|
}
|
|
317
|
-
// Enrich MCP servers with signing status
|
|
879
|
+
// Enrich MCP servers with signing status
|
|
318
880
|
const mcpIdDir = path.join(dir, '.opena2a', 'mcp-identities');
|
|
319
881
|
if (fs.existsSync(mcpIdDir)) {
|
|
320
882
|
for (const server of mcpServers) {
|
|
321
883
|
const idFile = path.join(mcpIdDir, `${server.name}.json`);
|
|
322
884
|
if (fs.existsSync(idFile)) {
|
|
323
885
|
server.verified = true;
|
|
886
|
+
server.risk = 'low';
|
|
324
887
|
}
|
|
325
888
|
}
|
|
326
889
|
}
|
|
327
|
-
|
|
890
|
+
// Calculate governance score
|
|
891
|
+
const partialResult = { scanTimestamp: new Date().toISOString(), scanDirectory: dir, agents, mcpServers, aiConfigs, identity };
|
|
892
|
+
const { governanceScore, deductions } = calculateGovernanceScore(partialResult);
|
|
893
|
+
const ungoverned = agents.filter((a) => a.governanceStatus === 'no governance').length;
|
|
894
|
+
const unverifiedServers = mcpServers.filter((s) => !s.verified).length;
|
|
895
|
+
const localLlms = agents.filter((a) => a.category === 'local-llm').length;
|
|
896
|
+
const result = {
|
|
897
|
+
scanTimestamp: new Date().toISOString(),
|
|
898
|
+
scanDirectory: dir,
|
|
899
|
+
summary: {
|
|
900
|
+
totalAgents: agents.length,
|
|
901
|
+
ungoverned,
|
|
902
|
+
mcpServers: mcpServers.length,
|
|
903
|
+
unverifiedServers,
|
|
904
|
+
localLlms,
|
|
905
|
+
aiConfigs: aiConfigs.length,
|
|
906
|
+
governanceScore,
|
|
907
|
+
// All deductions are recoverable -- every deduction maps to a finding
|
|
908
|
+
// with an actionable fix. Addressing all findings reaches 100/100.
|
|
909
|
+
recoverablePoints: deductions,
|
|
910
|
+
},
|
|
911
|
+
agents,
|
|
912
|
+
mcpServers,
|
|
913
|
+
aiConfigs,
|
|
914
|
+
identity,
|
|
915
|
+
findings: [],
|
|
916
|
+
};
|
|
917
|
+
result.findings = generateFindings(result);
|
|
328
918
|
if (options.format === 'json') {
|
|
329
919
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
330
|
-
return 0;
|
|
331
920
|
}
|
|
332
|
-
|
|
921
|
+
else {
|
|
922
|
+
process.stdout.write(formatText(result, options.verbose ?? false, dir) + '\n');
|
|
923
|
+
}
|
|
924
|
+
// Generate HTML report if requested
|
|
925
|
+
if (options.reportPath) {
|
|
926
|
+
const { generateDetectHtml } = await import('../report/detect-html.js');
|
|
927
|
+
const html = generateDetectHtml(result);
|
|
928
|
+
fs.writeFileSync(options.reportPath, html, 'utf-8');
|
|
929
|
+
if (!options.ci) {
|
|
930
|
+
const { exec } = await import('node:child_process');
|
|
931
|
+
const openCmd = os.platform() === 'darwin' ? 'open' : os.platform() === 'win32' ? 'start' : 'xdg-open';
|
|
932
|
+
exec(`${openCmd} "${options.reportPath}"`);
|
|
933
|
+
}
|
|
934
|
+
process.stdout.write(`Report: ${options.reportPath}\n`);
|
|
935
|
+
}
|
|
936
|
+
// Export CSV asset inventory if requested
|
|
937
|
+
if (options.exportCsv) {
|
|
938
|
+
const csv = generateAssetCsv(result);
|
|
939
|
+
fs.writeFileSync(options.exportCsv, csv, 'utf-8');
|
|
940
|
+
process.stdout.write(`Asset inventory: ${options.exportCsv}\n`);
|
|
941
|
+
}
|
|
333
942
|
return 0;
|
|
334
943
|
}
|
|
335
944
|
//# sourceMappingURL=detect.js.map
|