@shomra/agent 0.2.9 → 0.2.11
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/ai-usage.mjs +155 -0
- package/code-sast.mjs +38 -0
- package/discovery.mjs +123 -2
- package/package.json +2 -1
- package/shomra.mjs +104 -0
package/ai-usage.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI-USAGE inventory extractor (CLI port of the backend's checks/ai-usage.ts,
|
|
3
|
+
* kept in sync so both surfaces detect the same thing). Finds where a repo USES
|
|
4
|
+
* an LLM/AI provider in its own source — SDK imports + provider-specific call
|
|
5
|
+
* sites — so `shomra` can inventory "this code talks to OpenAI / a local model"
|
|
6
|
+
* as plain shadow-AI usage, independent of whether that usage is vulnerable.
|
|
7
|
+
*
|
|
8
|
+
* Dependency-free, line-oriented, low-false-positive: a provider is only claimed
|
|
9
|
+
* when a line either IMPORTS its SDK module or matches a provider-specific CALL
|
|
10
|
+
* signature — never from a bare mention of the word "openai" in prose.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Human label per category — shared by the report + backend. */
|
|
14
|
+
export const AI_USAGE_CATEGORY_LABEL = {
|
|
15
|
+
'llm-api': 'hosted LLM API',
|
|
16
|
+
'llm-framework': 'LLM framework',
|
|
17
|
+
'local-runtime': 'local model runtime',
|
|
18
|
+
'inference-gateway': 'inference gateway',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The provider catalog — every entry is an LLM/AI *usage* surface.
|
|
22
|
+
const PROVIDERS = [
|
|
23
|
+
{ id: 'openai', label: 'OpenAI', category: 'llm-api', npm: ['openai'], npmPrefix: ['@azure/openai'], py: ['openai'],
|
|
24
|
+
call: [/\b(?:Async)?(?:Azure)?OpenAI\s*\(/, /\bchat\.completions\.create\s*\(/, /\bresponses\.create\s*\(/, /\bembeddings\.create\s*\(/, /\bChatCompletion\.create\s*\(/, /\bopenai\.(?:ChatCompletion|Completion|Embedding)\b/] },
|
|
25
|
+
{ id: 'anthropic', label: 'Anthropic', category: 'llm-api', npm: ['@anthropic-ai/sdk', '@anthropic-ai/bedrock-sdk', '@anthropic-ai/vertex-sdk'], npmPrefix: ['@anthropic-ai/'], py: ['anthropic'],
|
|
26
|
+
call: [/\b(?:Async)?Anthropic(?:Bedrock|Vertex)?\s*\(/, /\bmessages\.create\s*\(/, /\bmessages\.stream\s*\(/] },
|
|
27
|
+
{ id: 'google-gemini', label: 'Google Gemini', category: 'llm-api', npm: ['@google/generative-ai', '@google/genai'], py: ['google.generativeai', 'google.genai'], pyRoot: ['google.generativeai', 'google.genai'],
|
|
28
|
+
call: [/\bGenerativeModel\s*\(/, /\bgenerate_content\s*\(/, /\bgetGenerativeModel\s*\(/] },
|
|
29
|
+
{ id: 'google-vertex', label: 'Google Vertex AI', category: 'llm-api', npm: ['@google-cloud/vertexai', '@google-cloud/aiplatform'], py: ['vertexai', 'google.cloud.aiplatform'], pyRoot: ['vertexai', 'google.cloud.aiplatform'],
|
|
30
|
+
call: [/\bTextGenerationModel\b/, /\bGenerativeModel\.from_pretrained\s*\(/] },
|
|
31
|
+
{ id: 'aws-bedrock', label: 'AWS Bedrock', category: 'llm-api', npm: ['@aws-sdk/client-bedrock-runtime', '@aws-sdk/client-bedrock'],
|
|
32
|
+
call: [/\bbedrock[-_]?runtime\b/i, /\bBedrockRuntime(?:Client)?\b/, /\binvoke_model(?:_with_response_stream)?\s*\(/, /\bclient\s*\(\s*['"]bedrock/i] },
|
|
33
|
+
{ id: 'cohere', label: 'Cohere', category: 'llm-api', npm: ['cohere-ai'], py: ['cohere'], call: [/\bcohere\.Client\w*\s*\(/, /\bClientV2\s*\(/] },
|
|
34
|
+
{ id: 'mistral', label: 'Mistral', category: 'llm-api', npm: ['@mistralai/mistralai'], py: ['mistralai'], call: [/\bMistral(?:Client|AsyncClient|)\s*\(/, /\bchat\.complete\s*\(/] },
|
|
35
|
+
{ id: 'groq', label: 'Groq', category: 'llm-api', npm: ['groq-sdk'], py: ['groq'], call: [/\bGroq\s*\(/] },
|
|
36
|
+
{ id: 'together', label: 'Together AI', category: 'llm-api', npm: ['together-ai'], py: ['together'], call: [/\bTogether\s*\(/] },
|
|
37
|
+
{ id: 'replicate', label: 'Replicate', category: 'llm-api', npm: ['replicate'], py: ['replicate'], call: [/\breplicate\.run\s*\(/, /\bReplicate\s*\(/] },
|
|
38
|
+
{ id: 'huggingface-inference', label: 'Hugging Face Inference', category: 'llm-api', npm: ['@huggingface/inference'], py: ['huggingface_hub'], call: [/\bInferenceClient\s*\(/] },
|
|
39
|
+
|
|
40
|
+
{ id: 'litellm', label: 'LiteLLM', category: 'inference-gateway', npm: ['litellm'], py: ['litellm'], call: [/\blitellm\.(?:a?completion|a?embedding)\s*\(/] },
|
|
41
|
+
{ id: 'openrouter', label: 'OpenRouter', category: 'inference-gateway', npm: ['openrouter'], call: [/\bopenrouter\.ai\b/i] },
|
|
42
|
+
{ id: 'vercel-ai', label: 'Vercel AI SDK', category: 'inference-gateway', npm: ['ai'], npmPrefix: ['@ai-sdk/'], call: [/\b(?:generateText|streamText|generateObject|streamObject)\s*\(/] },
|
|
43
|
+
|
|
44
|
+
{ id: 'langchain', label: 'LangChain', category: 'llm-framework', npmPrefix: ['@langchain/'], npm: ['langchain'], pyRoot: ['langchain'], call: [/\bChat(?:OpenAI|Anthropic|Google\w*|Bedrock|Vertex\w*|Cohere|Mistral\w*)\s*\(/, /\bLLMChain\s*\(/, /\bChatPromptTemplate\b/] },
|
|
45
|
+
{ id: 'langgraph', label: 'LangGraph', category: 'llm-framework', npm: ['@langchain/langgraph'], pyRoot: ['langgraph'], call: [/\bStateGraph\s*\(/] },
|
|
46
|
+
{ id: 'llama-index', label: 'LlamaIndex', category: 'llm-framework', npmPrefix: ['@llamaindex/'], npm: ['llamaindex'], pyRoot: ['llama_index'], call: [/\bVectorStoreIndex\b/, /\bServiceContext\b/] },
|
|
47
|
+
{ id: 'crewai', label: 'CrewAI', category: 'llm-framework', py: ['crewai'], pyRoot: ['crewai'], call: [/\bCrew\s*\(/, /\bAgent\s*\(\s*role\s*=/] },
|
|
48
|
+
{ id: 'autogen', label: 'AutoGen', category: 'llm-framework', py: ['autogen', 'pyautogen'], pyRoot: ['autogen', 'autogen_agentchat'], call: [/\bAssistantAgent\s*\(/, /\bConversableAgent\s*\(/] },
|
|
49
|
+
{ id: 'semantic-kernel', label: 'Semantic Kernel', category: 'llm-framework', py: ['semantic_kernel'], pyRoot: ['semantic_kernel'], npm: ['@microsoft/semantic-kernel'], call: [/\bKernel\.builder\b/] },
|
|
50
|
+
{ id: 'haystack', label: 'Haystack', category: 'llm-framework', py: ['haystack'], pyRoot: ['haystack'], call: [/\bPipeline\s*\(\s*\)/] },
|
|
51
|
+
{ id: 'dspy', label: 'DSPy', category: 'llm-framework', py: ['dspy'], pyRoot: ['dspy'], call: [/\bdspy\.(?:Predict|ChainOfThought|Signature)\b/] },
|
|
52
|
+
{ id: 'guidance', label: 'Guidance', category: 'llm-framework', py: ['guidance'], call: [/\bguidance\.\w+/] },
|
|
53
|
+
{ id: 'instructor', label: 'Instructor', category: 'llm-framework', py: ['instructor'], call: [/\binstructor\.(?:from_openai|patch|from_anthropic)\s*\(/] },
|
|
54
|
+
{ id: 'pydantic-ai', label: 'PydanticAI', category: 'llm-framework', py: ['pydantic_ai'], pyRoot: ['pydantic_ai'], call: [/\bAgent\s*\(\s*['"]/] },
|
|
55
|
+
|
|
56
|
+
{ id: 'ollama', label: 'Ollama', category: 'local-runtime', npm: ['ollama'], py: ['ollama'], call: [/\bollama\.(?:chat|generate|embeddings)\s*\(/] },
|
|
57
|
+
{ id: 'transformers', label: 'Transformers', category: 'local-runtime', py: ['transformers'], pyRoot: ['transformers'], call: [/\bpipeline\s*\(/, /\bAutoModel\w*\.from_pretrained\s*\(/] },
|
|
58
|
+
{ id: 'sentence-transformers', label: 'Sentence-Transformers', category: 'local-runtime', py: ['sentence_transformers'], call: [/\bSentenceTransformer\s*\(/] },
|
|
59
|
+
{ id: 'vllm', label: 'vLLM', category: 'local-runtime', py: ['vllm'], call: [/\bLLM\s*\(\s*model\s*=/, /\bSamplingParams\s*\(/] },
|
|
60
|
+
{ id: 'llama-cpp', label: 'llama.cpp', category: 'local-runtime', npm: ['node-llama-cpp'], py: ['llama_cpp'], call: [/\bLlama\s*\(\s*model_path\s*=/] },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const MODEL_ON_LINE = /\bmodel(?:_?id|_?name)?\s*[=:]\s*['"]([A-Za-z0-9][\w.:\/-]{1,80})['"]/;
|
|
64
|
+
const MAX_CODE_LEN = 240;
|
|
65
|
+
const clipLine = (s) => (s.length > MAX_CODE_LEN ? s.slice(0, MAX_CODE_LEN) + '…' : s);
|
|
66
|
+
const SCAN_EXT = /\.(py|ipynb|[mc]?[jt]sx?|java|kt|go|rb|php|cs|rs|scala)$/i;
|
|
67
|
+
export function isAiUsageScannable(file) {
|
|
68
|
+
return SCAN_EXT.test(String(file || ''));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function importSpecifiers(raw) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const m of raw.matchAll(/(?:\bfrom\s+|\brequire\s*\(\s*|\bimport\s*\(\s*|\bimport\s+)['"]([^'"]+)['"]/g)) out.push(m[1]);
|
|
74
|
+
let pm = raw.match(/^\s*from\s+([A-Za-z_][\w.]*)\s+import\b/);
|
|
75
|
+
if (pm) out.push(pm[1]);
|
|
76
|
+
pm = raw.match(/^\s*import\s+([A-Za-z_][\w.]*(?:\s*,\s*[A-Za-z_][\w.]*)*)/);
|
|
77
|
+
if (pm) for (const mod of pm[1].split(',')) out.push(mod.trim());
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function importMatches(spec, p) {
|
|
82
|
+
if (!spec) return false;
|
|
83
|
+
const s = spec.trim();
|
|
84
|
+
if (p.npm?.includes(s)) return true;
|
|
85
|
+
if (p.npmPrefix?.some((pre) => s === pre.replace(/\/$/, '') || s.startsWith(pre))) return true;
|
|
86
|
+
if (p.py?.includes(s)) return true;
|
|
87
|
+
if (p.pyRoot?.some((root) => s === root || s.startsWith(root + '.') || s.startsWith(root + '_'))) return true;
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Extract AI-usage sightings from one source file's text. */
|
|
92
|
+
export function scanAiUsage(text, file = '') {
|
|
93
|
+
if (!text || !isAiUsageScannable(file)) return [];
|
|
94
|
+
const out = [];
|
|
95
|
+
const lines = text.split(/\r?\n/);
|
|
96
|
+
for (let i = 0; i < lines.length; i++) {
|
|
97
|
+
const raw = lines[i];
|
|
98
|
+
const trimmed = raw.trim();
|
|
99
|
+
if (!trimmed) continue;
|
|
100
|
+
const ln = i + 1;
|
|
101
|
+
const isComment = trimmed.startsWith('#') || trimmed.startsWith('//') || trimmed.startsWith('*');
|
|
102
|
+
const specs = isComment ? [] : importSpecifiers(raw);
|
|
103
|
+
const modelOnLine = (raw.match(MODEL_ON_LINE) || [])[1];
|
|
104
|
+
const seenOnLine = new Set();
|
|
105
|
+
for (const p of PROVIDERS) {
|
|
106
|
+
const importedSpec = specs.find((s) => importMatches(s, p));
|
|
107
|
+
if (importedSpec) {
|
|
108
|
+
const key = `${p.id}:import`;
|
|
109
|
+
if (!seenOnLine.has(key)) {
|
|
110
|
+
seenOnLine.add(key);
|
|
111
|
+
out.push({ provider: p.id, label: p.label, category: p.category, kind: 'import', via: importedSpec, code: clipLine(trimmed), line: ln, file });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!isComment && p.call) {
|
|
115
|
+
const hit = p.call.find((re) => re.test(raw));
|
|
116
|
+
if (hit) {
|
|
117
|
+
const key = `${p.id}:call`;
|
|
118
|
+
if (!seenOnLine.has(key)) {
|
|
119
|
+
seenOnLine.add(key);
|
|
120
|
+
out.push({ provider: p.id, label: p.label, category: p.category, kind: 'call', via: (raw.match(hit) || [])[0] || p.label, code: clipLine(trimmed), ...(modelOnLine ? { model: modelOnLine } : {}), line: ln, file });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Cap on individual sites carried per provider row. */
|
|
130
|
+
const MAX_SITES_PER_PROVIDER = 60;
|
|
131
|
+
|
|
132
|
+
/** One inventory row per provider, aggregated across all files. */
|
|
133
|
+
export function rollupAiUsage(usages) {
|
|
134
|
+
const byProvider = new Map();
|
|
135
|
+
for (const u of usages) {
|
|
136
|
+
let row = byProvider.get(u.provider);
|
|
137
|
+
if (!row) {
|
|
138
|
+
row = { provider: u.provider, label: u.label, category: u.category, files: [], firstSite: null, sites: [], models: [], sightings: 0, hasCallSite: false };
|
|
139
|
+
byProvider.set(u.provider, row);
|
|
140
|
+
}
|
|
141
|
+
row.sightings++;
|
|
142
|
+
if (!row.files.includes(u.file)) row.files.push(u.file);
|
|
143
|
+
if (u.model && !row.models.includes(u.model)) row.models.push(u.model);
|
|
144
|
+
if (u.kind === 'call') row.hasCallSite = true;
|
|
145
|
+
if (row.sites.length < MAX_SITES_PER_PROVIDER) {
|
|
146
|
+
row.sites.push({ file: u.file, line: u.line, kind: u.kind, via: u.via, ...(u.code ? { code: u.code } : {}), ...(u.model ? { model: u.model } : {}) });
|
|
147
|
+
}
|
|
148
|
+
const better = u.kind === 'call' && (!row.firstSite || row.firstSite.kind !== 'call');
|
|
149
|
+
if (!row.firstSite || better) row.firstSite = { file: u.file, line: u.line, via: u.via, kind: u.kind };
|
|
150
|
+
}
|
|
151
|
+
for (const row of byProvider.values()) {
|
|
152
|
+
row.sites.sort((a, b) => a.file.localeCompare(b.file) || (b.kind === 'call' ? 1 : 0) - (a.kind === 'call' ? 1 : 0) || a.line - b.line);
|
|
153
|
+
}
|
|
154
|
+
return [...byProvider.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
155
|
+
}
|
package/code-sast.mjs
CHANGED
|
@@ -245,6 +245,25 @@ const PY_RULES = [
|
|
|
245
245
|
remediation: 'Never set allow_dangerous_deserialization=True on an index you did not build. Rebuild the vector store from source documents in your own environment, or use a non-pickle store format.',
|
|
246
246
|
cwe: 'CWE-502',
|
|
247
247
|
},
|
|
248
|
+
{
|
|
249
|
+
id: 'python.mcp_client',
|
|
250
|
+
title: 'MCP client integration (untrusted tool-output ingress)',
|
|
251
|
+
// LOW capability twin of js.mcp_client: importing the MCP SDK client surface
|
|
252
|
+
// says this file acts as an MCP host that feeds server tool output to a model.
|
|
253
|
+
severity: 'LOW',
|
|
254
|
+
category: 'agentic',
|
|
255
|
+
confidence: 0.55,
|
|
256
|
+
// Precise: `mcp.client.*` and `from mcp.client…import` are unambiguous;
|
|
257
|
+
// `from mcp import` only counts WITH ClientSession (excludes server-authoring
|
|
258
|
+
// imports); bare ClientSession( is NOT matched (aiohttp.ClientSession FP).
|
|
259
|
+
re: /\bfrom\s+mcp\.client[\w.]*\s+import\b|\bimport\s+mcp\.client\b|\bmcp\.client\.\w+|\bfrom\s+mcp\s+import\b[^\n]*\bClientSession\b/,
|
|
260
|
+
codeOnly: true,
|
|
261
|
+
sink: (m) => m[0].trim(),
|
|
262
|
+
source: 'MCP server tool output',
|
|
263
|
+
message: 'Acts as an MCP client/host: opens a ClientSession to MCP servers and passes their tool descriptions and results back to a model. Every server it reaches is an untrusted-input ingress — a poisoned tool description or result can hijack the agent (prompt injection / tool poisoning).',
|
|
264
|
+
remediation: 'Pin exactly which MCP servers this client may connect to and run each through governance before trusting it. Treat all server output as untrusted data, never instructions, and screen it (runtime firewall) before it reaches the model.',
|
|
265
|
+
cwe: 'CWE-829',
|
|
266
|
+
},
|
|
248
267
|
{
|
|
249
268
|
id: 'python.reduce_payload',
|
|
250
269
|
title: 'Custom __reduce__ (pickle RCE gadget)',
|
|
@@ -367,6 +386,25 @@ const JS_RULES = [
|
|
|
367
386
|
remediation: 'Avoid shelling out. If unavoidable, use execFile with a fixed binary and an argument array (never a shell string), and validate every argument.',
|
|
368
387
|
cwe: 'CWE-78',
|
|
369
388
|
},
|
|
389
|
+
{
|
|
390
|
+
id: 'js.mcp_client',
|
|
391
|
+
title: 'MCP client integration (untrusted tool-output ingress)',
|
|
392
|
+
// LOW capability signal: the /client SDK entrypoint or a *ClientTransport says
|
|
393
|
+
// this file acts as an MCP host — it connects to servers and feeds their tool
|
|
394
|
+
// output to a model. That is the toxic-flow ingress; on its own it is a lead.
|
|
395
|
+
severity: 'LOW',
|
|
396
|
+
category: 'agentic',
|
|
397
|
+
confidence: 0.55,
|
|
398
|
+
// /client subpath + transport class names are unambiguous. `new Client(` is NOT
|
|
399
|
+
// matched — too many libs export a generic Client. Not codeOnly: the /client
|
|
400
|
+
// import path lives inside a require()/import string.
|
|
401
|
+
re: /@modelcontextprotocol\/sdk\/client|\b(StdioClientTransport|SSEClientTransport|StreamableHTTPClientTransport|WebSocketClientTransport)\b/,
|
|
402
|
+
sink: (m) => m[0].trim(),
|
|
403
|
+
source: 'MCP server tool output',
|
|
404
|
+
message: 'Acts as an MCP client/host: connects to MCP servers and passes their tool descriptions and results back to a model. Every server it reaches is an untrusted-input ingress — a poisoned tool description or result can hijack the agent (prompt injection / tool poisoning).',
|
|
405
|
+
remediation: 'Pin exactly which MCP servers this client may connect to and run each through governance before trusting it. Treat all server output as untrusted data, never instructions, and screen it (runtime firewall) before it reaches the model.',
|
|
406
|
+
cwe: 'CWE-829',
|
|
407
|
+
},
|
|
370
408
|
{
|
|
371
409
|
id: 'js.decode_and_run',
|
|
372
410
|
title: 'Encoded payload decode-and-run',
|
package/discovery.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import fs from 'node:fs';
|
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { scanAiUsage, rollupAiUsage, isAiUsageScannable, AI_USAGE_CATEGORY_LABEL } from './ai-usage.mjs';
|
|
23
24
|
|
|
24
25
|
const HOME = os.homedir();
|
|
25
26
|
const APPDATA = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
|
|
@@ -142,13 +143,17 @@ const isVectorIndex = (base) =>
|
|
|
142
143
|
VECTOR_INDEX_BASENAMES.has(base.toLowerCase()) ||
|
|
143
144
|
VECTOR_INDEX_EXTS.has((base.slice(base.lastIndexOf('.') + 1) || '').toLowerCase());
|
|
144
145
|
|
|
146
|
+
/** Cap on SOURCE files collected for AI-usage-in-code scanning (bounded so a big
|
|
147
|
+
* monorepo can't turn the sweep into a full read of every .py/.ts on disk). */
|
|
148
|
+
const MAX_SOURCE_FILES = 600;
|
|
149
|
+
|
|
145
150
|
/**
|
|
146
151
|
* One bounded breadth-first walk per root that collects every file of interest.
|
|
147
|
-
* Returns { mcp:[], rules:[], manifests:[], env:[], vector:[] }
|
|
152
|
+
* Returns { mcp:[], rules:[], manifests:[], env:[], vector:[], source:[] } lists.
|
|
148
153
|
* Depth- and count-limited so it never turns into a full-disk crawl.
|
|
149
154
|
*/
|
|
150
155
|
function walkWorkspace(roots) {
|
|
151
|
-
const found = { mcp: [], rules: [], manifests: [], env: [], vector: [] };
|
|
156
|
+
const found = { mcp: [], rules: [], manifests: [], env: [], vector: [], source: [] };
|
|
152
157
|
const seenDir = new Set();
|
|
153
158
|
let budget = 40_000; // total directories visited across all roots
|
|
154
159
|
const maxDepth = 6;
|
|
@@ -163,6 +168,9 @@ function walkWorkspace(roots) {
|
|
|
163
168
|
} else if (MANIFEST_NAMES.has(base)) found.manifests.push({ file: full, parentBase });
|
|
164
169
|
else if (isEnvFile(base)) found.env.push({ file: full, parentBase });
|
|
165
170
|
else if (isVectorIndex(base)) found.vector.push({ file: full, parentBase });
|
|
171
|
+
// Application source — collected (capped) so discoverAiUsageInCode can find
|
|
172
|
+
// the LLM/AI providers this code actually calls, not just what a manifest declares.
|
|
173
|
+
else if (found.source.length < MAX_SOURCE_FILES && isAiUsageScannable(base)) found.source.push({ file: full, parentBase });
|
|
166
174
|
};
|
|
167
175
|
|
|
168
176
|
for (const root of roots) {
|
|
@@ -427,6 +435,117 @@ export function discoverAiDependencies(roots = [process.cwd()], files = null) {
|
|
|
427
435
|
return assets;
|
|
428
436
|
}
|
|
429
437
|
|
|
438
|
+
// ── AI usage in code (SDK imports + provider call sites) ─────────
|
|
439
|
+
// The dependency scan above sees which AI SDKs a manifest DECLARES; this sees
|
|
440
|
+
// which LLM/AI providers the code actually CALLS (an `openai` import + a
|
|
441
|
+
// `chat.completions.create`, a LangChain chain, `ollama.chat`) — the shadow-AI
|
|
442
|
+
// usage a manifest can miss (a transitive dep, a vendored client) and can't
|
|
443
|
+
// localize (which file, which model). Surfaced regardless of whether it is
|
|
444
|
+
// vulnerable. One AI_TOOL asset per provider, category 'code-usage'.
|
|
445
|
+
export function discoverAiUsageInCode(roots = [process.cwd()], files = null) {
|
|
446
|
+
const walk = files || walkWorkspace(roots);
|
|
447
|
+
const usages = [];
|
|
448
|
+
for (const { file } of walk.source || []) {
|
|
449
|
+
if (!isAiUsageScannable(path.basename(file))) continue;
|
|
450
|
+
const text = readText(file, 300_000);
|
|
451
|
+
if (text == null) continue;
|
|
452
|
+
for (const u of scanAiUsage(text, file)) usages.push(u);
|
|
453
|
+
}
|
|
454
|
+
const assets = [];
|
|
455
|
+
for (const row of rollupAiUsage(usages)) {
|
|
456
|
+
const site = row.firstSite;
|
|
457
|
+
assets.push({
|
|
458
|
+
type: 'AI_TOOL',
|
|
459
|
+
name: `${row.label} (in code)`,
|
|
460
|
+
identifier: `ai-usage:${row.provider}`,
|
|
461
|
+
vendor: 'ai-sdk',
|
|
462
|
+
metadata: {
|
|
463
|
+
category: 'code-usage',
|
|
464
|
+
provider: row.provider,
|
|
465
|
+
aiCategory: row.category,
|
|
466
|
+
aiCategoryLabel: AI_USAGE_CATEGORY_LABEL[row.category],
|
|
467
|
+
fileCount: row.files.length,
|
|
468
|
+
files: row.files.slice(0, 10),
|
|
469
|
+
models: row.models.slice(0, 10),
|
|
470
|
+
callSites: row.sightings,
|
|
471
|
+
hasCallSite: row.hasCallSite,
|
|
472
|
+
firstSite: site ? { file: site.file, line: site.line } : null,
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return assets;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── MCP client / host SDK usage in code ──────────────────────────
|
|
480
|
+
// A repo that depends on the MCP SDK acts as an MCP HOST: it connects out to MCP
|
|
481
|
+
// servers and feeds their (untrusted) tool output back into a model — the
|
|
482
|
+
// toxic-flow / lethal-trifecta ingress. This is distinct from the MCP SERVERS a
|
|
483
|
+
// machine is CONFIGURED to launch (discoverMcpServers, keyed as servers) and from
|
|
484
|
+
// generic AI SDKs (discoverAiDependencies): here the code itself is the client.
|
|
485
|
+
// Manifest-level detection can't prove which SDK surface is used, so the on-disk
|
|
486
|
+
// SAST rules (js.mcp_client / python.mcp_client) confirm the client role from
|
|
487
|
+
// actual imports; this lens surfaces the dependency so shadow MCP hosts are seen.
|
|
488
|
+
const NPM_MCP_CLIENT = new Set(['mcp-use', 'mcp-client']);
|
|
489
|
+
const NPM_MCP_CLIENT_PREFIX = ['@modelcontextprotocol/', '@mastra/mcp', '@langchain/mcp'];
|
|
490
|
+
const PY_MCP_CLIENT = ['mcp', 'fastmcp', 'mcp-use', 'mcpadapt', 'langchain-mcp-adapters'];
|
|
491
|
+
|
|
492
|
+
function npmMcpClientDeps(pkg) {
|
|
493
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}), ...(pkg.optionalDependencies || {}) };
|
|
494
|
+
const hits = [];
|
|
495
|
+
for (const name of Object.keys(deps)) {
|
|
496
|
+
if (NPM_MCP_CLIENT.has(name) || NPM_MCP_CLIENT_PREFIX.some((p) => name.startsWith(p))) hits.push(name);
|
|
497
|
+
}
|
|
498
|
+
return hits;
|
|
499
|
+
}
|
|
500
|
+
function pyMcpClientDeps(text) {
|
|
501
|
+
const hits = [];
|
|
502
|
+
for (const pkg of PY_MCP_CLIENT) {
|
|
503
|
+
const re = new RegExp(`(^|[^a-z0-9_.-])${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_.-]|$)`, 'im');
|
|
504
|
+
if (re.test(text)) hits.push(pkg);
|
|
505
|
+
}
|
|
506
|
+
return hits;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function discoverMcpClients(roots = [process.cwd()], files = null) {
|
|
510
|
+
const walk = files || walkWorkspace(roots);
|
|
511
|
+
const byPkg = new Map(); // `${eco}:${pkg}` -> { pkg, eco, manifests:Set }
|
|
512
|
+
const add = (eco, pkg, manifest) => {
|
|
513
|
+
const key = `${eco}:${pkg}`;
|
|
514
|
+
if (!byPkg.has(key)) byPkg.set(key, { pkg, eco, manifests: new Set() });
|
|
515
|
+
byPkg.get(key).manifests.add(manifest);
|
|
516
|
+
};
|
|
517
|
+
for (const { file } of walk.manifests) {
|
|
518
|
+
const base = path.basename(file);
|
|
519
|
+
if (base === 'package.json') {
|
|
520
|
+
const json = readJson(file);
|
|
521
|
+
if (!json) continue;
|
|
522
|
+
for (const pkg of npmMcpClientDeps(json)) add('npm', pkg, file);
|
|
523
|
+
} else {
|
|
524
|
+
const text = readText(file, 100_000);
|
|
525
|
+
if (text == null) continue;
|
|
526
|
+
for (const pkg of pyMcpClientDeps(text)) add('pip', pkg, file);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const assets = [];
|
|
530
|
+
for (const { pkg, eco, manifests } of byPkg.values()) {
|
|
531
|
+
const list = [...manifests];
|
|
532
|
+
assets.push({
|
|
533
|
+
type: 'AI_TOOL',
|
|
534
|
+
name: `${pkg} (${eco})`,
|
|
535
|
+
identifier: `mcp-client:${eco}:${pkg}`,
|
|
536
|
+
vendor: 'mcp-client',
|
|
537
|
+
metadata: {
|
|
538
|
+
category: 'mcp-client',
|
|
539
|
+
ecosystem: eco,
|
|
540
|
+
package: pkg,
|
|
541
|
+
usedInProjects: list.length,
|
|
542
|
+
manifests: list.slice(0, 10),
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
return assets;
|
|
547
|
+
}
|
|
548
|
+
|
|
430
549
|
// ── API keys sitting in .env files ───────────────────────────────
|
|
431
550
|
|
|
432
551
|
const KEY_NAME_VENDOR = {
|
|
@@ -792,6 +911,8 @@ export function discoverAll(roots = [process.cwd()], opts = {}) {
|
|
|
792
911
|
...discoverMcpServers(scanRoots, files),
|
|
793
912
|
...discoverRulesFiles(scanRoots, files),
|
|
794
913
|
...discoverAiDependencies(scanRoots, files),
|
|
914
|
+
...discoverAiUsageInCode(scanRoots, files),
|
|
915
|
+
...discoverMcpClients(scanRoots, files),
|
|
795
916
|
...discoverVectorStores(scanRoots, files),
|
|
796
917
|
...discoverDotenvKeys(scanRoots, files),
|
|
797
918
|
...discoverAiTools(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Shomra — a local-first security scanner and runtime firewall for AI agents, MCP servers, prompts, and models. Gates AI artifacts in your editor and CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"guard-signals.mjs",
|
|
19
19
|
"code-sast.mjs",
|
|
20
20
|
"model-refs.mjs",
|
|
21
|
+
"ai-usage.mjs",
|
|
21
22
|
"README.md",
|
|
22
23
|
"LICENSE",
|
|
23
24
|
"NOTICE"
|
package/shomra.mjs
CHANGED
|
@@ -1656,6 +1656,108 @@ function printWhy(res) {
|
|
|
1656
1656
|
console.log('');
|
|
1657
1657
|
}
|
|
1658
1658
|
|
|
1659
|
+
// ── shomra provenance: which of these changed files did an AI agent write? ──
|
|
1660
|
+
//
|
|
1661
|
+
// shomra provenance [--staged | --base main] [--trailer] [--fail-on-blocked] [--json]
|
|
1662
|
+
//
|
|
1663
|
+
// Every mutating tool call the runtime firewall screened was recorded with its
|
|
1664
|
+
// target path and ALLOW/FLAG/BLOCK decision. This joins a real git diff against
|
|
1665
|
+
// that record, so a commit can carry an EVIDENCE-BACKED statement of authorship
|
|
1666
|
+
// instead of a "Co-Authored-By" line anyone can type.
|
|
1667
|
+
//
|
|
1668
|
+
// ⚠ "Unattributed" means the firewall has no record — NOT that a human wrote it.
|
|
1669
|
+
// With the hook uninstalled every file is unattributed, so the output always
|
|
1670
|
+
// states its coverage and never claims human authorship. Don't rewrite that copy.
|
|
1671
|
+
|
|
1672
|
+
/** All changed paths (not just AI artifacts) for provenance. */
|
|
1673
|
+
function gitChangedPaths(root, { staged, base }) {
|
|
1674
|
+
const run = (args) => {
|
|
1675
|
+
try {
|
|
1676
|
+
return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString();
|
|
1677
|
+
} catch {
|
|
1678
|
+
return null;
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
let out = null;
|
|
1682
|
+
if (staged) {
|
|
1683
|
+
out = run('diff --cached --name-only --relative --diff-filter=ACM');
|
|
1684
|
+
} else if (base) {
|
|
1685
|
+
for (const b of [`origin/${base}`, base]) {
|
|
1686
|
+
out = run(`diff --name-only --relative --diff-filter=ACM ${b}...HEAD`);
|
|
1687
|
+
if (out !== null) break;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
if (out === null) out = run('diff HEAD~1 --name-only --relative --diff-filter=ACM');
|
|
1691
|
+
if (out === null) return null;
|
|
1692
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
async function cmdProvenance(flags, positional) {
|
|
1696
|
+
const root = path.resolve(flags.path || positional[0] || '.');
|
|
1697
|
+
const staged = !!flags.staged;
|
|
1698
|
+
const base = flags.base || (staged ? null : process.env.GITHUB_BASE_REF || 'main');
|
|
1699
|
+
|
|
1700
|
+
const paths = gitChangedPaths(root, { staged, base });
|
|
1701
|
+
if (paths === null) {
|
|
1702
|
+
console.error(red('✗') + ' Not a git repository (or no diff available). Run inside a repo, or pass --base <ref>.');
|
|
1703
|
+
process.exit(1);
|
|
1704
|
+
}
|
|
1705
|
+
if (!paths.length) {
|
|
1706
|
+
if (flags.json) console.log(JSON.stringify({ files: [], agentAuthored: 0, coverage: 'NO_TELEMETRY', summary: 'no changed files' }, null, 2));
|
|
1707
|
+
else console.log(green('\n ✓ No changed files to attribute.\n'));
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const cfg = loadConfig();
|
|
1712
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
1713
|
+
let res;
|
|
1714
|
+
try {
|
|
1715
|
+
res = await api(url, apiKey, '/gate/provenance', {
|
|
1716
|
+
paths,
|
|
1717
|
+
repo: flags.repo || process.env.GITHUB_REPOSITORY || undefined,
|
|
1718
|
+
sessionId: flags.session || undefined,
|
|
1719
|
+
sinceHours: flags.since ? Number(flags.since) : undefined,
|
|
1720
|
+
});
|
|
1721
|
+
} catch (e) {
|
|
1722
|
+
// Provenance is an evidence lookup, not a guard — a backend outage must not
|
|
1723
|
+
// block a commit. Say so plainly instead of silently reporting "no agents".
|
|
1724
|
+
console.error(yellow('!') + ` Provenance unavailable (${e.message}). Authorship not established.`);
|
|
1725
|
+
process.exit(flags['fail-on-blocked'] ? 1 : 0);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
if (flags.json) {
|
|
1729
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1730
|
+
} else if (flags.trailer) {
|
|
1731
|
+
for (const t of res.trailers || []) console.log(t);
|
|
1732
|
+
} else {
|
|
1733
|
+
const noTel = res.coverage === 'NO_TELEMETRY';
|
|
1734
|
+
console.log('');
|
|
1735
|
+
console.log(` ${bold('Commit provenance')} ${dim(`· ${res.files.length} changed file(s)`)}`);
|
|
1736
|
+
console.log(` ${noTel ? yellow('⚠ ' + res.summary) : res.summary}`);
|
|
1737
|
+
if (noTel) {
|
|
1738
|
+
console.log(dim(' No firewall telemetry for this range — this is NOT a claim that a human wrote them.'));
|
|
1739
|
+
console.log(dim(' Install the runtime hook with ') + bold('shomra protect') + dim(' to attribute future work.'));
|
|
1740
|
+
}
|
|
1741
|
+
console.log('');
|
|
1742
|
+
for (const f of res.files.slice(0, 40)) {
|
|
1743
|
+
const tag =
|
|
1744
|
+
f.authorship === 'AGENT' ? cyan('agent') : f.authorship === 'BLOCKED_ATTEMPT' ? red('blocked') : dim('unattributed');
|
|
1745
|
+
const who = f.agents?.length ? dim(` ${f.agents.join(', ')}`) : '';
|
|
1746
|
+
const amb = f.ambiguous ? yellow(' ~ambiguous') : '';
|
|
1747
|
+
console.log(` ${tag.padEnd(22)} ${f.path}${who}${amb}`);
|
|
1748
|
+
}
|
|
1749
|
+
if (res.files.length > 40) console.log(dim(` …and ${res.files.length - 40} more`));
|
|
1750
|
+
console.log('');
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// A path the firewall BLOCKED that changed anyway is the signal worth failing
|
|
1754
|
+
// on: either the guard was bypassed, or something wrote it outside the agent.
|
|
1755
|
+
if (flags['fail-on-blocked'] && res.blockedAttempts > 0) {
|
|
1756
|
+
console.error(red('✗') + ` ${res.blockedAttempts} file(s) the firewall blocked were modified anyway.`);
|
|
1757
|
+
process.exit(1);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1659
1761
|
// ── shomra install-precommit: gate staged AI artifacts at commit time ──
|
|
1660
1762
|
//
|
|
1661
1763
|
// shomra install-precommit [dir] [--force]
|
|
@@ -3951,6 +4053,7 @@ ${bold('COMMANDS')}
|
|
|
3951
4053
|
${cyan('init')} Configure + enroll this machine ${dim('--key shm_live_… [--url <backend>]')}
|
|
3952
4054
|
${cyan('protect')} Wire the runtime firewall for every coding agent ${dim('[--local] [--force]')}
|
|
3953
4055
|
${cyan('install-hook')} Wire the runtime firewall into ONE agent ${dim('[--agent claude|cursor|windsurf|gemini|codex|copilot|cline|aider|all] [--global]')}
|
|
4056
|
+
${cyan('provenance')} Which changed files an AI agent wrote ${dim('[--staged | --base main] [--trailer] [--fail-on-blocked] [--json]')}
|
|
3954
4057
|
${cyan('install-precommit')} Gate staged AI artifacts on git commit ${dim('[dir] [--force]')}
|
|
3955
4058
|
${cyan('doctor')} ${bold('Am I safe?')} Posture of this machine's AI setup ${dim('[--json]')}
|
|
3956
4059
|
|
|
@@ -4163,6 +4266,7 @@ const COMMANDS = {
|
|
|
4163
4266
|
baseline: (f, p) => cmdBaseline(f, p),
|
|
4164
4267
|
fix: (f, p) => cmdFix(f, p),
|
|
4165
4268
|
why: (f, p) => cmdWhy(f, p),
|
|
4269
|
+
provenance: (f, p) => cmdProvenance(f, p),
|
|
4166
4270
|
'install-precommit': (f, p) => cmdInstallPrecommit(f, p),
|
|
4167
4271
|
'scan-zip': (f, p) => cmdScanZip(f, p),
|
|
4168
4272
|
'model-scan': (f, p) => cmdModelScan(f, p),
|