opena2a-cli 0.5.12 → 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.
@@ -2,8 +2,9 @@
2
2
  /**
3
3
  * opena2a detect -- Shadow AI Agent Audit
4
4
  *
5
- * Scans the local machine for running AI agents and MCP servers,
6
- * then reports their identity and governance status.
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
- /** Agent patterns to search for in the process list. */
54
+ // ---------------------------------------------------------------------------
55
+ // Agent patterns
56
+ // ---------------------------------------------------------------------------
53
57
  const AGENT_PATTERNS = [
54
- { name: 'Claude Code', patterns: [/\bclaude\b/i, /@anthropic-ai\/claude-code/i] },
55
- { name: 'Cursor', patterns: [/\bcursor\b/i, /Cursor\.app/i] },
56
- { name: 'GitHub Copilot', patterns: [/\bcopilot\b/i] },
57
- { name: 'Windsurf', patterns: [/\bwindsurf\b/i, /Windsurf/] },
58
- { name: 'Aider', patterns: [/\baider\b/i] },
59
- { name: 'Continue', patterns: [/\bcontinue\b/i] },
60
- { name: 'Cline', patterns: [/\bcline\b/i] },
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: '~/.claude/mcp_servers.json' },
64
- { path: '.cursor/mcp.json', label: '~/.cursor/mcp.json' },
65
- { path: '.config/windsurf/mcp.json', label: '~/.config/windsurf/mcp.json' },
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
- * Scan running processes for AI agents.
70
- * Runs a single `ps aux` and parses once.
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
- const matches = agent.patterns.some((p) => p.test(line));
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
- * Parse an MCP config file and extract server entries.
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
- servers.push({
135
- name,
136
- transport,
137
- source: label,
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
- * Scan for MCP server config files.
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
- const fullPath = path.join(home, loc.path);
156
- servers.push(...parseMcpConfig(fullPath, loc.label));
279
+ servers.push(...parseMcpConfig(path.join(home, loc.path), loc.label));
157
280
  }
158
- // VSCode MCP extensions -- scan ~/.vscode/extensions/*/mcp.json
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
- const mcpPath = path.join(vscodeExtDir, entry.name, 'mcp.json');
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
- const fullPath = path.join(targetDir, filename);
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
- * Check for AIM identity and governance files.
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
- // Count MCP server identities separately
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
- const files = fs.readdirSync(mcpIdDir).filter((f) => f.endsWith('.json'));
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
- // Check for SOUL.md files
202
- const soulPaths = [
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
- * Format text output for the detect command.
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
- // Running AI Agents
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 (result.agents.length === 0) {
237
- lines.push((0, colors_js_1.dim)(' No AI agents detected in running processes'));
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 result.agents) {
241
- const nameCol = agent.name.padEnd(20);
242
- const pidCol = `PID ${agent.pid}`.padEnd(13);
243
- const idStatus = agent.identityStatus === 'identified'
244
- ? (0, colors_js_1.green)(agent.identityStatus)
245
- : (0, colors_js_1.yellow)(agent.identityStatus);
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
- // MCP Servers
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
- for (const server of result.mcpServers) {
261
- const nameCol = server.name.padEnd(20);
262
- const transportCol = server.transport.padEnd(9);
263
- const sourceCol = server.source.padEnd(40);
264
- const verifiedLabel = server.verified
265
- ? (0, colors_js_1.green)('verified')
266
- : (0, colors_js_1.dim)('not verified');
267
- lines.push(` ${nameCol}${transportCol}${sourceCol}${verifiedLabel}`);
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
- // Identity Status
272
- lines.push((0, colors_js_1.bold)('Identity Status'));
273
- const aimLabel = result.identity.aimIdentities > 0 ? (0, colors_js_1.green)('initialized') : (0, colors_js_1.yellow)('not initialized');
274
- lines.push(` AIM project: ${aimLabel}`);
275
- if (result.identity.mcpIdentities > 0) {
276
- lines.push(` MCP identities: ${result.identity.mcpIdentities} server(s) signed`);
277
- }
278
- lines.push(` Governance files: ${result.identity.soulFiles === 0 ? 'none' : `${result.identity.soulFiles} SOUL.md found`}`);
279
- lines.push(` Capability policies: ${result.identity.capabilityPolicies === 0 ? 'none' : String(result.identity.capabilityPolicies)}`);
280
- lines.push('');
281
- // Next Steps
282
- lines.push((0, colors_js_1.bold)('Next Steps'));
283
- lines.push(` ${(0, colors_js_1.cyan)('opena2a identity create --name my-agent')} Create an agent identity`);
284
- lines.push(` ${(0, colors_js_1.cyan)('opena2a init')} Initialize security posture`);
285
- lines.push(` ${(0, colors_js_1.cyan)('opena2a scan-soul')} Scan governance coverage`);
286
- if (verbose) {
287
- lines.push('');
288
- lines.push((0, colors_js_1.dim)('Detection methods: process list (ps aux), MCP config files, AIM identity directories'));
289
- lines.push((0, colors_js_1.dim)(`Target directory: ${targetDir}`));
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
- * Main detect command entry point.
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
- // Update totalAgents count
864
+ const aiConfigs = scanAiConfigs(dir);
311
865
  identity.totalAgents = agents.length;
312
- // Enrich agents with identity info if .opena2a exists in target dir
866
+ // Enrich agents with identity/governance from project context
313
867
  if (identity.aimIdentities > 0) {
314
- // Mark agents as identified if AIM identity exists in the project
315
- // In a real implementation, this would match agent names to identities
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 from .opena2a/mcp-identities/
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
- const result = { agents, mcpServers, identity };
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
- process.stdout.write(formatText(result, options.verbose ?? false, dir) + '\n');
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