agentverify 1.4.0 → 2.0.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/CHANGELOG.md +34 -1
- package/README.md +1 -1
- package/dist/cli.js +774 -28
- package/dist/cli.mjs +776 -28
- package/dist/guard.d.mts +105 -0
- package/dist/guard.d.ts +105 -0
- package/dist/guard.js +249 -0
- package/dist/guard.mjs +220 -0
- package/dist/sdk.d.mts +4 -0
- package/dist/sdk.d.ts +4 -0
- package/package.json +28 -8
package/dist/cli.mjs
CHANGED
|
@@ -4,9 +4,645 @@ import {
|
|
|
4
4
|
} from "./chunk-XVA2M2HS.mjs";
|
|
5
5
|
|
|
6
6
|
// src/cli.ts
|
|
7
|
-
import { readFileSync, readdirSync, statSync, appendFileSync, writeFileSync } from "fs";
|
|
8
|
-
import { join, relative, extname } from "path";
|
|
9
|
-
|
|
7
|
+
import { readFileSync as readFileSync4, readdirSync as readdirSync3, statSync as statSync2, appendFileSync, writeFileSync } from "fs";
|
|
8
|
+
import { join as join2, relative as relative2, extname as extname2 } from "path";
|
|
9
|
+
|
|
10
|
+
// src/mcp.ts
|
|
11
|
+
import { createInterface } from "readline";
|
|
12
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
13
|
+
import { resolve } from "path";
|
|
14
|
+
|
|
15
|
+
// src/files.ts
|
|
16
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "fs";
|
|
17
|
+
import { basename, extname, join, relative, sep } from "path";
|
|
18
|
+
import { homedir, platform } from "os";
|
|
19
|
+
var SCANNABLE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx", ".py", ".json", ".yaml", ".yml", ".toml", ".md", ".mjs", ".cjs", ".sh"]);
|
|
20
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "vendor", "coverage", ".cache", ".venv", "venv", "__pycache__", "out", "target"]);
|
|
21
|
+
var SKIP_FILES = /* @__PURE__ */ new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "Cargo.lock", "composer.lock", "tsconfig.json"]);
|
|
22
|
+
var MAX_FILES = 60;
|
|
23
|
+
var MAX_TOTAL_BYTES = 5 * 1024 * 1024;
|
|
24
|
+
var MAX_FILE_BYTES = 400 * 1024;
|
|
25
|
+
function filePriority(relPath) {
|
|
26
|
+
const p = relPath.toLowerCase().replace(/\\/g, "/");
|
|
27
|
+
const name = p.split("/").pop() ?? p;
|
|
28
|
+
let score = 0;
|
|
29
|
+
if (/(^|\/)(tests?|__tests__|spec|e2e|fixtures?|examples?)\//.test(p) || /\.(test|spec)\.[a-z]+$/.test(name)) score -= 80;
|
|
30
|
+
if (/(agent|tool|mcp|prompt|crew|graph|server|config|policy|skill)/.test(name)) score += 30;
|
|
31
|
+
if (/(^|\/)(agents?|tools?|mcp|src|app|lib)\//.test(p)) score += 10;
|
|
32
|
+
if (name.endsWith(".md")) score -= 20;
|
|
33
|
+
if (name === "skill.md" || name === "agents.md" || name === "claude.md") score += 40;
|
|
34
|
+
return score - p.split("/").length;
|
|
35
|
+
}
|
|
36
|
+
function walk(dir, out) {
|
|
37
|
+
let entries;
|
|
38
|
+
try {
|
|
39
|
+
entries = readdirSync(dir);
|
|
40
|
+
} catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (SKIP_DIRS.has(entry) || SKIP_FILES.has(entry)) continue;
|
|
45
|
+
const full = join(dir, entry);
|
|
46
|
+
let st;
|
|
47
|
+
try {
|
|
48
|
+
st = statSync(full);
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (st.isDirectory()) walk(full, out);
|
|
53
|
+
else if (SCANNABLE_EXTENSIONS.has(extname(entry).toLowerCase())) out.push(full);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function pickFiles(target) {
|
|
57
|
+
const notes = [];
|
|
58
|
+
let st;
|
|
59
|
+
try {
|
|
60
|
+
st = statSync(target);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(`Path not found: ${target}`);
|
|
63
|
+
}
|
|
64
|
+
if (st.isFile()) {
|
|
65
|
+
if (st.size > MAX_TOTAL_BYTES) throw new Error(`${basename(target)} is larger than 5MB, which is the most one scan can take.`);
|
|
66
|
+
return { files: [{ path: basename(target), content: readFileSync(target, "utf8") }], notes };
|
|
67
|
+
}
|
|
68
|
+
const all = [];
|
|
69
|
+
walk(target, all);
|
|
70
|
+
if (all.length === 0) throw new Error(`No agent files found in ${target}. Agent Verify reads ${[...SCANNABLE_EXTENSIONS].join(" ")} files.`);
|
|
71
|
+
const ranked = all.map((full) => ({ full, rel: relative(target, full).split(sep).join("/") })).sort((a, b) => filePriority(b.rel) - filePriority(a.rel) || a.rel.localeCompare(b.rel));
|
|
72
|
+
const files = [];
|
|
73
|
+
let total = 0;
|
|
74
|
+
let tooBig = 0;
|
|
75
|
+
for (const { full, rel } of ranked) {
|
|
76
|
+
if (files.length >= MAX_FILES) break;
|
|
77
|
+
let size = 0;
|
|
78
|
+
try {
|
|
79
|
+
size = statSync(full).size;
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (size > MAX_FILE_BYTES) {
|
|
84
|
+
tooBig++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (total + size > MAX_TOTAL_BYTES) break;
|
|
88
|
+
files.push({ path: rel, content: readFileSync(full, "utf8") });
|
|
89
|
+
total += size;
|
|
90
|
+
}
|
|
91
|
+
const left = ranked.length - files.length - tooBig;
|
|
92
|
+
if (left > 0) notes.push(`${left} lower-priority file(s) were left out to stay within one scan (${MAX_FILES} files / 5MB).`);
|
|
93
|
+
if (tooBig > 0) notes.push(`${tooBig} file(s) over 400KB were skipped.`);
|
|
94
|
+
return { files, notes };
|
|
95
|
+
}
|
|
96
|
+
function combineFiles(files) {
|
|
97
|
+
if (files.length === 1) return files[0].content;
|
|
98
|
+
return files.map((f) => `// ===== FILE: ${f.path} =====
|
|
99
|
+
${f.content}`).join("\n\n");
|
|
100
|
+
}
|
|
101
|
+
function locateLine(files, combinedLine) {
|
|
102
|
+
if (files.length === 1) return { path: files[0].path, line: combinedLine };
|
|
103
|
+
let cursor = 1;
|
|
104
|
+
for (let i = 0; i < files.length; i++) {
|
|
105
|
+
const start = cursor + 1;
|
|
106
|
+
const lines = files[i].content.split("\n").length;
|
|
107
|
+
if (combinedLine >= start && combinedLine < start + lines) return { path: files[i].path, line: combinedLine - start + 1 };
|
|
108
|
+
cursor = start + lines + 1;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function knownMcpConfigPaths(cwd = process.cwd()) {
|
|
113
|
+
const home = homedir();
|
|
114
|
+
const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
|
|
115
|
+
const claudeDesktop = platform() === "win32" ? join(appData, "Claude", "claude_desktop_config.json") : platform() === "darwin" ? join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
116
|
+
return [
|
|
117
|
+
{ client: "Claude Code (this project)", path: join(cwd, ".mcp.json") },
|
|
118
|
+
{ client: "Claude Code (user)", path: join(home, ".claude.json") },
|
|
119
|
+
{ client: "Claude Desktop", path: claudeDesktop },
|
|
120
|
+
{ client: "Cursor (this project)", path: join(cwd, ".cursor", "mcp.json") },
|
|
121
|
+
{ client: "Cursor (user)", path: join(home, ".cursor", "mcp.json") },
|
|
122
|
+
{ client: "VS Code (this project)", path: join(cwd, ".vscode", "mcp.json") },
|
|
123
|
+
{ client: "Windsurf", path: join(home, ".codeium", "windsurf", "mcp_config.json") }
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
function extractMcpServers(raw) {
|
|
127
|
+
let data;
|
|
128
|
+
try {
|
|
129
|
+
data = JSON.parse(raw);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (!data || typeof data !== "object") return null;
|
|
134
|
+
const servers = {};
|
|
135
|
+
const take = (obj, prefix = "") => {
|
|
136
|
+
if (!obj || typeof obj !== "object") return;
|
|
137
|
+
for (const key of ["mcpServers", "servers"]) {
|
|
138
|
+
const block = obj[key];
|
|
139
|
+
if (block && typeof block === "object" && !Array.isArray(block)) {
|
|
140
|
+
for (const [name, def] of Object.entries(block)) servers[prefix + name] = def;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
take(data);
|
|
145
|
+
const projects = data.projects;
|
|
146
|
+
if (projects && typeof projects === "object") {
|
|
147
|
+
for (const [proj, value] of Object.entries(projects)) take(value, `${basename(proj)}/`);
|
|
148
|
+
}
|
|
149
|
+
return Object.keys(servers).length ? servers : null;
|
|
150
|
+
}
|
|
151
|
+
var SECRET_NAME = /(TOKEN|KEY|SECRET|PASSWORD|PASSWD|PAT|AUTH|CREDENTIAL|COOKIE|SESSION)/i;
|
|
152
|
+
var SECRET_VALUE = /^(sk-|sk_|ghp_|gho_|github_pat_|xox[abp]-|AKIA|AIza|hf_|npm_|glpat-|eyJ)/;
|
|
153
|
+
function mask(value) {
|
|
154
|
+
return value.length <= 4 ? "****redacted" : `${value.slice(0, 4)}****redacted`;
|
|
155
|
+
}
|
|
156
|
+
function redactMcpSecrets(servers) {
|
|
157
|
+
const clean = (node, parentKey = "") => {
|
|
158
|
+
if (typeof node === "string") {
|
|
159
|
+
if (/^\$\{|^<|^\$[A-Z_]/.test(node)) return node;
|
|
160
|
+
if (SECRET_NAME.test(parentKey) || SECRET_VALUE.test(node)) return mask(node);
|
|
161
|
+
const flag = node.match(/^(--?[A-Za-z0-9_-]*(?:token|key|secret|password|auth)[A-Za-z0-9_-]*=)(.+)$/i);
|
|
162
|
+
if (flag) return flag[1] + mask(flag[2]);
|
|
163
|
+
return node;
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(node)) return node.map((item) => clean(item, parentKey));
|
|
166
|
+
if (node && typeof node === "object") {
|
|
167
|
+
const out = {};
|
|
168
|
+
for (const [k, v] of Object.entries(node)) out[k] = clean(v, k);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
return node;
|
|
172
|
+
};
|
|
173
|
+
return clean(servers);
|
|
174
|
+
}
|
|
175
|
+
function existingMcpConfigs(cwd = process.cwd()) {
|
|
176
|
+
return knownMcpConfigPaths(cwd).filter((c2) => existsSync(c2.path));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/mcp.ts
|
|
180
|
+
var SUPPORTED_PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
181
|
+
var PRICING_URL = "https://aimodularity.com/agentverify/pricing/";
|
|
182
|
+
var DASHBOARD_URL = "https://aimodularity.com/agentverify/dashboard/";
|
|
183
|
+
var TOOLS = [
|
|
184
|
+
{
|
|
185
|
+
name: "scan_path",
|
|
186
|
+
title: "Scan agent files",
|
|
187
|
+
description: "Scan an AI agent for security risks before it is deployed: dangerous tool permissions, prompt injection paths, exposed secrets, risky MCP servers, missing approval gates, SQL/SSRF/code-execution risks, and framework-specific issues (LangChain, CrewAI, AutoGen, OpenAI Agents SDK, Claude Agent SDK). Give a file or a folder; a folder is scanned as one report using the most important agent files (up to 60 files / 5MB). Returns a verdict, a 0-100 security score (higher is safer) and findings with file and line.",
|
|
188
|
+
inputSchema: {
|
|
189
|
+
type: "object",
|
|
190
|
+
properties: { path: { type: "string", description: "File or folder to scan. Relative paths are resolved from the current project." } },
|
|
191
|
+
required: ["path"],
|
|
192
|
+
additionalProperties: false
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "scan_code",
|
|
197
|
+
title: "Scan agent code",
|
|
198
|
+
description: "Scan a snippet of agent code, a tool definition, a system prompt or an MCP config for security risks. Use this for code that is not saved to a file yet. Returns a verdict, a security score and findings.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
content: { type: "string", description: "The code or config to scan." },
|
|
203
|
+
fileName: { type: "string", description: "A file name with the right extension (e.g. agent.py, mcp.json) so the language is detected." }
|
|
204
|
+
},
|
|
205
|
+
required: ["content"],
|
|
206
|
+
additionalProperties: false
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "scan_mcp_config",
|
|
211
|
+
title: "Check my MCP servers",
|
|
212
|
+
description: "Check the MCP servers configured on this computer (Claude Code, Claude Desktop, Cursor, VS Code, Windsurf) for risks such as broad filesystem access, unpinned packages, remote servers without auth, auto-approved tools and API keys stored in plain text. Only the server definitions are read, and secret values are masked on this computer before anything is sent.",
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: "object",
|
|
215
|
+
properties: { path: { type: "string", description: "Optional: a specific MCP config file. Leave empty to check all known locations." } },
|
|
216
|
+
additionalProperties: false
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
];
|
|
220
|
+
var SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
221
|
+
var SEVERITY_LABEL = { critical: "CRITICAL", high: "HIGH", medium: "MEDIUM", low: "LOW" };
|
|
222
|
+
function verdictLine(result) {
|
|
223
|
+
const verdict = result.verdict === "VERIFIED" ? "VERIFIED" : result.verdict === "NOT_ASSESSED" ? "NOT ASSESSED" : "NOT VERIFIED";
|
|
224
|
+
return `**Verdict: ${verdict}** \u2014 Security score ${result.riskScore}/100 (higher is safer, ${result.riskLevel})`;
|
|
225
|
+
}
|
|
226
|
+
function fixText(f) {
|
|
227
|
+
if (f.fixLocked) return `Fix: included with Agent Verify Pro (${PRICING_URL})`;
|
|
228
|
+
const steps = (f.recommendedFix ?? "").trim();
|
|
229
|
+
const code = (f.quickFix ?? f.fixCode ?? "").trim();
|
|
230
|
+
if (!steps && !code) return null;
|
|
231
|
+
return [steps ? `Fix: ${steps}` : "", code ? "```\n" + code + "\n```" : ""].filter(Boolean).join("\n");
|
|
232
|
+
}
|
|
233
|
+
function formatReport(result, opts) {
|
|
234
|
+
const findings = [...result.findings ?? []].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9));
|
|
235
|
+
const counts = ["critical", "high", "medium", "low"].map((s) => [s, findings.filter((f) => f.severity === s).length]).filter(([, n]) => n > 0);
|
|
236
|
+
const lines = [`# Agent Verify: ${opts.title}`, "", verdictLine(result)];
|
|
237
|
+
lines.push(findings.length ? `Findings: ${counts.map(([s, n]) => `${n} ${s}`).join(", ")}` : "No findings.");
|
|
238
|
+
if (result.reportInsights?.topBlocker) lines.push(`Fix first: ${result.reportInsights.topBlocker}`);
|
|
239
|
+
for (const note of opts.notes ?? []) lines.push(`Note: ${note}`);
|
|
240
|
+
findings.forEach((f, i) => {
|
|
241
|
+
const where = typeof f.line === "number" && f.line > 0 ? opts.files ? locateLine(opts.files, f.line) : { path: opts.title, line: f.line } : null;
|
|
242
|
+
lines.push("", `## ${i + 1}. [${SEVERITY_LABEL[f.severity] ?? f.severity}] ${f.title} (${f.code})`);
|
|
243
|
+
if (where) lines.push(`Location: ${where.path}:${where.line}`);
|
|
244
|
+
if (f.evidence) lines.push(`Evidence: ${f.evidence}`);
|
|
245
|
+
lines.push(`What is wrong: ${f.whatIsWrong}`);
|
|
246
|
+
if (f.whyItMatters) lines.push(`Why it matters: ${f.whyItMatters}`);
|
|
247
|
+
const fix = fixText(f);
|
|
248
|
+
if (fix) lines.push(fix);
|
|
249
|
+
});
|
|
250
|
+
const locked = findings.filter((f) => f.fixLocked).length;
|
|
251
|
+
if (locked > 0) lines.push("", `Step-by-step fixes and corrected code for ${locked} finding(s) are included with Pro: ${PRICING_URL}`);
|
|
252
|
+
if (result.reportUrl) lines.push("", `Full report: ${result.reportUrl}`);
|
|
253
|
+
lines.push("", "Agent Verify is an automated, evidence-based check, not a guarantee. Re-scan after making changes.");
|
|
254
|
+
return lines.join("\n");
|
|
255
|
+
}
|
|
256
|
+
function textResult(text, isError = false, structured) {
|
|
257
|
+
return { content: [{ type: "text", text }], ...isError ? { isError: true } : {}, ...structured ? { structuredContent: structured } : {} };
|
|
258
|
+
}
|
|
259
|
+
function summary(result) {
|
|
260
|
+
return {
|
|
261
|
+
verdict: result.verdict,
|
|
262
|
+
securityScore: result.riskScore,
|
|
263
|
+
riskLevel: result.riskLevel,
|
|
264
|
+
findings: (result.findings ?? []).map((f) => ({ code: f.code, severity: f.severity, title: f.title, line: f.line ?? null })),
|
|
265
|
+
reportUrl: result.reportUrl ?? null
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async function callTool(name, args, opts) {
|
|
269
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
270
|
+
if (!tool) return textResult(`Unknown tool: ${name}`, true);
|
|
271
|
+
if (!opts.apiKey) {
|
|
272
|
+
return textResult(
|
|
273
|
+
"Agent Verify needs an API key. Create a free one in the dashboard (API / CLI tab): " + DASHBOARD_URL + "\nThen set AGENTVERIFY_API_KEY in this MCP server's environment and restart it.",
|
|
274
|
+
true
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const doScan = opts.scanFn ?? scan;
|
|
278
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
279
|
+
try {
|
|
280
|
+
if (name === "scan_code") {
|
|
281
|
+
const content2 = typeof args.content === "string" ? args.content : "";
|
|
282
|
+
if (!content2.trim()) return textResult('Nothing to scan: "content" is empty.', true);
|
|
283
|
+
const fileName = typeof args.fileName === "string" && args.fileName.trim() ? args.fileName.trim() : "agent";
|
|
284
|
+
const result2 = await doScan({ content: content2, fileName, platform: "MCP" }, { apiKey: opts.apiKey, apiUrl: opts.apiUrl });
|
|
285
|
+
return textResult(formatReport(result2, { title: fileName }), false, summary(result2));
|
|
286
|
+
}
|
|
287
|
+
if (name === "scan_path") {
|
|
288
|
+
const raw = typeof args.path === "string" ? args.path.trim() : "";
|
|
289
|
+
if (!raw) return textResult('Give a file or folder "path" to scan.', true);
|
|
290
|
+
const target = resolve(cwd, raw);
|
|
291
|
+
const picked = pickFiles(target);
|
|
292
|
+
const fileName = picked.files.length === 1 ? picked.files[0].path : `${raw.replace(/[\\/]+$/, "") || "project"} (${picked.files.length} files)`;
|
|
293
|
+
const result2 = await doScan({ content: combineFiles(picked.files), fileName, platform: "MCP" }, { apiKey: opts.apiKey, apiUrl: opts.apiUrl });
|
|
294
|
+
const notes = [...picked.notes];
|
|
295
|
+
if (picked.files.length > 1) notes.unshift(`Scanned ${picked.files.length} files together as one report.`);
|
|
296
|
+
return textResult(formatReport(result2, { title: fileName, files: picked.files, notes }), false, summary(result2));
|
|
297
|
+
}
|
|
298
|
+
const explicit = typeof args.path === "string" && args.path.trim() ? resolve(cwd, args.path.trim()) : "";
|
|
299
|
+
const configs = explicit ? [{ client: "Config file", path: explicit }] : existingMcpConfigs(cwd);
|
|
300
|
+
if (configs.length === 0) return textResult('No MCP config files were found in the usual places (Claude Code, Claude Desktop, Cursor, VS Code, Windsurf). Pass a "path" to check a specific file.');
|
|
301
|
+
const servers = {};
|
|
302
|
+
const read = [];
|
|
303
|
+
for (const cfg of configs) {
|
|
304
|
+
let rawText = "";
|
|
305
|
+
try {
|
|
306
|
+
rawText = readFileSync2(cfg.path, "utf8");
|
|
307
|
+
} catch {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const found = extractMcpServers(rawText);
|
|
311
|
+
if (!found) continue;
|
|
312
|
+
read.push(`${cfg.client}: ${Object.keys(found).length} server(s)`);
|
|
313
|
+
for (const [server, def] of Object.entries(found)) servers[`${cfg.client} \u203A ${server}`] = def;
|
|
314
|
+
}
|
|
315
|
+
if (Object.keys(servers).length === 0) return textResult(`Found ${configs.length} config file(s), but none of them define any MCP servers.`);
|
|
316
|
+
const content = JSON.stringify({ mcpServers: redactMcpSecrets(servers) }, null, 2);
|
|
317
|
+
const result = await doScan({ content, fileName: "mcp.json", platform: "MCP" }, { apiKey: opts.apiKey, apiUrl: opts.apiUrl });
|
|
318
|
+
return textResult(
|
|
319
|
+
formatReport(result, { title: `MCP servers (${Object.keys(servers).length})`, notes: [`Checked: ${read.join("; ")}`, "Secret values were masked on this computer before scanning."] }),
|
|
320
|
+
false,
|
|
321
|
+
summary(result)
|
|
322
|
+
);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
return textResult(`Scan failed: ${err instanceof Error ? err.message : String(err)}`, true);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async function handleMessage(msg, opts) {
|
|
328
|
+
const isNotification = msg.id === void 0 || msg.id === null;
|
|
329
|
+
const reply = (result) => isNotification ? null : { jsonrpc: "2.0", id: msg.id, result };
|
|
330
|
+
const fail = (code, message) => isNotification ? null : { jsonrpc: "2.0", id: msg.id ?? null, error: { code, message } };
|
|
331
|
+
switch (msg.method) {
|
|
332
|
+
case "initialize": {
|
|
333
|
+
const requested = typeof msg.params?.protocolVersion === "string" ? msg.params.protocolVersion : "";
|
|
334
|
+
return reply({
|
|
335
|
+
protocolVersion: SUPPORTED_PROTOCOLS.includes(requested) ? requested : SUPPORTED_PROTOCOLS[0],
|
|
336
|
+
capabilities: { tools: { listChanged: false } },
|
|
337
|
+
serverInfo: { name: "agentverify", title: "Agent Verify", version: opts.version },
|
|
338
|
+
instructions: "Use Agent Verify to check AI agent code, tools, prompts and MCP configs for security risks before deploying. Run scan_path on the agent folder after making changes, and fix critical and high findings first."
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
case "ping":
|
|
342
|
+
return reply({});
|
|
343
|
+
case "tools/list":
|
|
344
|
+
return reply({ tools: TOOLS });
|
|
345
|
+
case "tools/call": {
|
|
346
|
+
const name = typeof msg.params?.name === "string" ? msg.params.name : "";
|
|
347
|
+
const args = msg.params?.arguments && typeof msg.params.arguments === "object" ? msg.params.arguments : {};
|
|
348
|
+
return reply(await callTool(name, args, opts));
|
|
349
|
+
}
|
|
350
|
+
case "resources/list":
|
|
351
|
+
return reply({ resources: [] });
|
|
352
|
+
case "prompts/list":
|
|
353
|
+
return reply({ prompts: [] });
|
|
354
|
+
default:
|
|
355
|
+
if (typeof msg.method === "string" && msg.method.startsWith("notifications/")) return null;
|
|
356
|
+
return fail(-32601, `Method not found: ${msg.method}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function runMcpServer(opts) {
|
|
360
|
+
return new Promise((done) => {
|
|
361
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
362
|
+
const send = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
|
|
363
|
+
let pending = 0;
|
|
364
|
+
let closed = false;
|
|
365
|
+
const maybeDone = () => {
|
|
366
|
+
if (closed && pending === 0) done();
|
|
367
|
+
};
|
|
368
|
+
rl.on("line", (line) => {
|
|
369
|
+
const text = line.trim();
|
|
370
|
+
if (!text) return;
|
|
371
|
+
let msg;
|
|
372
|
+
try {
|
|
373
|
+
msg = JSON.parse(text);
|
|
374
|
+
} catch {
|
|
375
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
pending++;
|
|
379
|
+
handleMessage(msg, opts).then((res) => {
|
|
380
|
+
if (res) send(res);
|
|
381
|
+
}).catch((err) => send({ jsonrpc: "2.0", id: msg.id ?? null, error: { code: -32603, message: err instanceof Error ? err.message : "Internal error" } })).finally(() => {
|
|
382
|
+
pending--;
|
|
383
|
+
maybeDone();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
rl.on("close", () => {
|
|
387
|
+
closed = true;
|
|
388
|
+
maybeDone();
|
|
389
|
+
});
|
|
390
|
+
process.stderr.write(`Agent Verify MCP server ${opts.version} ready${opts.apiKey ? "" : " (no API key set: tools will explain how to add one)"}
|
|
391
|
+
`);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/profile.ts
|
|
396
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
|
|
397
|
+
var SUPPORTED_PROFILES = ["owasp-agentic-skills-2026"];
|
|
398
|
+
var SUPPORTED_ASSESSMENT_SCHEMA = "1.1.0";
|
|
399
|
+
var LOCAL_LIMITS = { maxFiles: 5e3, maxFileBytes: 8 * 1024 * 1024, maxTotalBytes: 32 * 1024 * 1024, maxDepth: 64 };
|
|
400
|
+
var VENDORED_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "__pycache__"]);
|
|
401
|
+
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
402
|
+
".png",
|
|
403
|
+
".jpg",
|
|
404
|
+
".jpeg",
|
|
405
|
+
".gif",
|
|
406
|
+
".webp",
|
|
407
|
+
".ico",
|
|
408
|
+
".bmp",
|
|
409
|
+
".tif",
|
|
410
|
+
".tiff",
|
|
411
|
+
".avif",
|
|
412
|
+
".pdf",
|
|
413
|
+
".woff",
|
|
414
|
+
".woff2",
|
|
415
|
+
".ttf",
|
|
416
|
+
".otf",
|
|
417
|
+
".eot",
|
|
418
|
+
".zip",
|
|
419
|
+
".gz",
|
|
420
|
+
".tgz",
|
|
421
|
+
".tar",
|
|
422
|
+
".bz2",
|
|
423
|
+
".xz",
|
|
424
|
+
".7z",
|
|
425
|
+
".mp3",
|
|
426
|
+
".mp4",
|
|
427
|
+
".mov",
|
|
428
|
+
".wav",
|
|
429
|
+
".ogg",
|
|
430
|
+
".webm",
|
|
431
|
+
".wasm",
|
|
432
|
+
".so",
|
|
433
|
+
".dll",
|
|
434
|
+
".dylib",
|
|
435
|
+
".exe",
|
|
436
|
+
".class",
|
|
437
|
+
".jar",
|
|
438
|
+
".pyc",
|
|
439
|
+
".sqlite",
|
|
440
|
+
".db"
|
|
441
|
+
]);
|
|
442
|
+
var PackageError = class extends Error {
|
|
443
|
+
};
|
|
444
|
+
function displayPath(path) {
|
|
445
|
+
return JSON.stringify(safeText(path));
|
|
446
|
+
}
|
|
447
|
+
function safeText(text) {
|
|
448
|
+
let out = "";
|
|
449
|
+
for (const ch of text) {
|
|
450
|
+
const cp = ch.codePointAt(0);
|
|
451
|
+
const hidden = cp < 32 || cp >= 127 && cp <= 159 || cp === 173 || cp === 1564 || cp === 6158 || cp === 65279 || cp >= 8203 && cp <= 8207 || cp >= 8234 && cp <= 8238 || cp >= 8288 && cp <= 8292 || cp >= 8294 && cp <= 8297 || cp >= 917504 && cp <= 917999 || cp >= 55296 && cp <= 57343;
|
|
452
|
+
out += hidden ? `\\u{${cp.toString(16).toUpperCase().padStart(4, "0")}}` : ch;
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
function nameProblem(name) {
|
|
457
|
+
for (let i = 0; i < name.length; i++) {
|
|
458
|
+
const c2 = name.charCodeAt(i);
|
|
459
|
+
if (c2 >= 55296 && c2 <= 56319) {
|
|
460
|
+
const next = name.charCodeAt(i + 1);
|
|
461
|
+
if (next >= 56320 && next <= 57343) {
|
|
462
|
+
i++;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
return "contains an unpaired UTF-16 surrogate";
|
|
466
|
+
}
|
|
467
|
+
if (c2 >= 56320 && c2 <= 57343) return "contains an unpaired UTF-16 surrogate";
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
var strictUtf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
472
|
+
function decodeStrict(bytes) {
|
|
473
|
+
return strictUtf8.decode(bytes);
|
|
474
|
+
}
|
|
475
|
+
function listEntries(dir, shown) {
|
|
476
|
+
const entries = [];
|
|
477
|
+
if (process.platform === "win32") {
|
|
478
|
+
for (const d of readdirSync2(dir, { withFileTypes: true })) {
|
|
479
|
+
const problem = nameProblem(d.name);
|
|
480
|
+
if (problem) throw new PackageError(`An entry in ${displayPath(shown)} ${problem}, so it cannot be sent faithfully.`);
|
|
481
|
+
entries.push({ name: d.name, kind: d.isSymbolicLink() ? "symlink" : d.isDirectory() ? "dir" : d.isFile() ? "file" : "other" });
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
for (const d of readdirSync2(dir, { withFileTypes: true, encoding: "buffer" })) {
|
|
485
|
+
let name;
|
|
486
|
+
try {
|
|
487
|
+
name = decodeStrict(d.name);
|
|
488
|
+
} catch {
|
|
489
|
+
throw new PackageError(`An entry in ${displayPath(shown)} has a name that is not valid UTF-8, so it cannot be sent faithfully.`);
|
|
490
|
+
}
|
|
491
|
+
entries.push({ name, kind: d.isSymbolicLink() ? "symlink" : d.isDirectory() ? "dir" : d.isFile() ? "file" : "other" });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
495
|
+
}
|
|
496
|
+
var symlinkMessage = (rel) => `${displayPath(rel)} is a symbolic link. The CLI does not follow symbolic links, and a package containing one cannot be represented faithfully. Replace it with the file or directory it points to, or remove it.`;
|
|
497
|
+
var specialMessage = (rel) => `${displayPath(rel)} is not a regular file or directory (it is a device, socket or pipe), so it cannot be sent.`;
|
|
498
|
+
function readRegularFile(full, rel, budget) {
|
|
499
|
+
let fd;
|
|
500
|
+
try {
|
|
501
|
+
fd = openSync(full, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
502
|
+
} catch (e) {
|
|
503
|
+
const code = e.code;
|
|
504
|
+
if (code === "ELOOP") throw new PackageError(symlinkMessage(rel));
|
|
505
|
+
throw new PackageError(`Could not read ${displayPath(rel)}: ${code ?? "unknown error"}.`);
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
const st = fstatSync(fd);
|
|
509
|
+
if (!st.isFile()) throw new PackageError(specialMessage(rel));
|
|
510
|
+
if (st.size > LOCAL_LIMITS.maxFileBytes) throw new PackageError(`${displayPath(rel)} is ${st.size} bytes, over the CLI's local read guard of ${LOCAL_LIMITS.maxFileBytes} bytes.`);
|
|
511
|
+
const bytes = readFileSync3(fd);
|
|
512
|
+
if (bytes.length > LOCAL_LIMITS.maxFileBytes) throw new PackageError(`${displayPath(rel)} grew past the CLI's local read guard while it was being read.`);
|
|
513
|
+
budget.total += bytes.length;
|
|
514
|
+
if (budget.total > LOCAL_LIMITS.maxTotalBytes) throw new PackageError(`The package is larger than the CLI's local guard of ${LOCAL_LIMITS.maxTotalBytes} bytes.`);
|
|
515
|
+
return bytes;
|
|
516
|
+
} finally {
|
|
517
|
+
closeSync(fd);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
var extensionOf = (name) => {
|
|
521
|
+
const i = name.lastIndexOf(".");
|
|
522
|
+
return i <= 0 ? "" : name.slice(i).toLowerCase();
|
|
523
|
+
};
|
|
524
|
+
function buildPackage(root) {
|
|
525
|
+
let rootStat;
|
|
526
|
+
try {
|
|
527
|
+
rootStat = lstatSync(root);
|
|
528
|
+
} catch {
|
|
529
|
+
throw new PackageError(`${displayPath(root)} does not exist or cannot be read.`);
|
|
530
|
+
}
|
|
531
|
+
if (rootStat.isSymbolicLink()) throw new PackageError(symlinkMessage(root));
|
|
532
|
+
if (!rootStat.isDirectory()) throw new PackageError(`${displayPath(root)} is not a directory. A profile assessment takes a directory.`);
|
|
533
|
+
const files = [];
|
|
534
|
+
const skipped = [];
|
|
535
|
+
const budget = { total: 0 };
|
|
536
|
+
const walk2 = (dir, prefix, depth) => {
|
|
537
|
+
if (depth > LOCAL_LIMITS.maxDepth) throw new PackageError(`The package is nested deeper than ${LOCAL_LIMITS.maxDepth} directories.`);
|
|
538
|
+
for (const entry of listEntries(dir, prefix || ".")) {
|
|
539
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
540
|
+
const full = `${dir}/${entry.name}`;
|
|
541
|
+
if (entry.kind === "symlink") throw new PackageError(symlinkMessage(rel));
|
|
542
|
+
if (entry.kind === "other") throw new PackageError(specialMessage(rel));
|
|
543
|
+
if (entry.kind === "dir") {
|
|
544
|
+
if (VENDORED_DIRS.has(entry.name)) {
|
|
545
|
+
skipped.push({ path: rel, reason: "vendored directory" });
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
walk2(full, rel, depth + 1);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (BINARY_EXTENSIONS.has(extensionOf(entry.name))) {
|
|
552
|
+
skipped.push({ path: rel, reason: "binary asset" });
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (files.length >= LOCAL_LIMITS.maxFiles) throw new PackageError(`The package has more than ${LOCAL_LIMITS.maxFiles} files, over the CLI's local guard.`);
|
|
556
|
+
const bytes = readRegularFile(full, rel, budget);
|
|
557
|
+
let content;
|
|
558
|
+
try {
|
|
559
|
+
content = decodeStrict(bytes);
|
|
560
|
+
} catch {
|
|
561
|
+
throw new PackageError(`${displayPath(rel)} is not valid UTF-8 text, so it cannot be sent faithfully. If it is a binary asset, give it a recognized binary extension or remove it from the package.`);
|
|
562
|
+
}
|
|
563
|
+
files.push({ path: rel, content });
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
walk2(root, "", 0);
|
|
567
|
+
if (files.length === 0) throw new PackageError(`${displayPath(root)} contains no files to assess.`);
|
|
568
|
+
return { files, skipped };
|
|
569
|
+
}
|
|
570
|
+
function serializeRequest(profile, files) {
|
|
571
|
+
return JSON.stringify({ profile, files });
|
|
572
|
+
}
|
|
573
|
+
async function postProfile(apiUrl, apiKey, body, timeoutMs) {
|
|
574
|
+
const controller = new AbortController();
|
|
575
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
576
|
+
try {
|
|
577
|
+
const response = await fetch(apiUrl, {
|
|
578
|
+
method: "POST",
|
|
579
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "User-Agent": "agentverify-cli/2.0.0" },
|
|
580
|
+
body,
|
|
581
|
+
signal: controller.signal
|
|
582
|
+
});
|
|
583
|
+
const text = await response.text();
|
|
584
|
+
let json = null;
|
|
585
|
+
try {
|
|
586
|
+
json = JSON.parse(text);
|
|
587
|
+
} catch {
|
|
588
|
+
json = null;
|
|
589
|
+
}
|
|
590
|
+
return { kind: "response", status: response.status, text, json };
|
|
591
|
+
} catch (e) {
|
|
592
|
+
if (e instanceof Error && e.name === "AbortError") return { kind: "failure", message: `Request timed out after ${timeoutMs}ms` };
|
|
593
|
+
return { kind: "failure", message: `Request failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
594
|
+
} finally {
|
|
595
|
+
clearTimeout(timer);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
599
|
+
var str = (v) => typeof v === "string" ? safeText(v) : typeof v === "number" || typeof v === "boolean" ? String(v) : "";
|
|
600
|
+
function formatAssessment(body, skipped) {
|
|
601
|
+
const a = isObj(body.assessment) ? body.assessment : {};
|
|
602
|
+
const profile = isObj(a.profile) ? a.profile : {};
|
|
603
|
+
const pkg = isObj(a.package) ? a.package : {};
|
|
604
|
+
const lines = [];
|
|
605
|
+
lines.push("Agent Verify profile assessment");
|
|
606
|
+
lines.push(`Profile: ${str(body.profile)}`);
|
|
607
|
+
lines.push(`Framework: ${str(profile.framework)} (upstream ${str(profile.upstreamRepo)} @ ${str(profile.upstreamCommit).slice(0, 12)}, ${str(profile.upstreamStatus)}; profile version ${str(profile.agentverifyProfileVersion)})`);
|
|
608
|
+
lines.push(`Assessment schema: ${str(a.schemaVersion)}`);
|
|
609
|
+
lines.push(`Interpretation: scanner ${str(profile.scannerVersion)}, engine ${str(profile.assessmentEngineVersion)}, risk rubric ${str(profile.riskRubricVersion)}, key allowlist ${str(profile.keyAllowlistVersion)}, normalization ${str(profile.normalizationVersion)}`);
|
|
610
|
+
lines.push(`Package: ${str(pkg.fileCount)} files, digest ${str(pkg.digest)}`);
|
|
611
|
+
lines.push(`Attestation: ${body.attestation === null ? "none (this profile assessment is unsigned)" : "present (not verified by this CLI)"}`);
|
|
612
|
+
lines.push("");
|
|
613
|
+
for (const control of Array.isArray(a.controls) ? a.controls : []) {
|
|
614
|
+
if (!isObj(control)) continue;
|
|
615
|
+
const cov = isObj(control.coverage) ? control.coverage : {};
|
|
616
|
+
lines.push(`${str(control.controlId)} ${str(control.title)}: ${str(control.status)} (confidence ${str(control.confidence)})`);
|
|
617
|
+
lines.push(` ${str(cov.evidenceObserved)} evidence observed, ${str(cov.gapIdentified)} gaps identified, ${str(cov.notAssessed)} not assessed, of ${str(cov.total)} checks`);
|
|
618
|
+
for (const check of Array.isArray(control.checks) ? control.checks : []) {
|
|
619
|
+
if (isObj(check)) lines.push(` ${str(check.checkId)} ${str(check.status)}: ${str(check.title)}`);
|
|
620
|
+
}
|
|
621
|
+
lines.push("");
|
|
622
|
+
}
|
|
623
|
+
const gaps = (Array.isArray(a.evidence) ? a.evidence : []).filter((e) => isObj(e) && e.polarity === "gap");
|
|
624
|
+
lines.push(`Gaps identified (${gaps.length}):`);
|
|
625
|
+
for (const g of gaps) {
|
|
626
|
+
lines.push(` [${str(g.severity)}] ${str(g.kind)}: ${str(g.summary)}`);
|
|
627
|
+
const locations = (Array.isArray(g.locations) ? g.locations : []).filter(isObj).slice(0, 3).map((l) => `${str(l.file)}${l.line !== void 0 ? `:${str(l.line)}` : ""}`);
|
|
628
|
+
if (locations.length) lines.push(` at ${locations.join(", ")}`);
|
|
629
|
+
}
|
|
630
|
+
if (gaps.length === 0) lines.push(" none");
|
|
631
|
+
const notImplemented = Array.isArray(a.notImplementedControls) ? a.notImplementedControls.map(str).join(", ") : "";
|
|
632
|
+
lines.push("");
|
|
633
|
+
lines.push(`Not assessed by this profile version: ${notImplemented || "none"}`);
|
|
634
|
+
for (const note of Array.isArray(a.notes) ? a.notes : []) lines.push(`Note: ${str(note)}`);
|
|
635
|
+
if (skipped.length > 0) {
|
|
636
|
+
lines.push("");
|
|
637
|
+
lines.push(`Skipped from the package (${skipped.length}), not sent:`);
|
|
638
|
+
for (const s of skipped.slice(0, 20)) lines.push(` ${displayPath(s.path)}: ${s.reason}`);
|
|
639
|
+
if (skipped.length > 20) lines.push(` and ${skipped.length - 20} more`);
|
|
640
|
+
}
|
|
641
|
+
return lines;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/cli.ts
|
|
645
|
+
var VERSION = "2.0.0";
|
|
10
646
|
var API_URL = process.env.AGENTVERIFY_API_URL ?? "https://agentverify-api.agentverify.workers.dev/v1/scan";
|
|
11
647
|
var A2SPA_DOCS_URL = "https://aimodularity.com/A2SPA/docs";
|
|
12
648
|
var EXIT_OK = 0;
|
|
@@ -26,20 +662,20 @@ var c = {
|
|
|
26
662
|
};
|
|
27
663
|
var write = (value = "") => process.stdout.write(`${value}
|
|
28
664
|
`);
|
|
29
|
-
var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".ts", ".py", ".json", ".yaml", ".yml", ".md", ".mjs", ".cjs"]);
|
|
665
|
+
var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx", ".py", ".json", ".yaml", ".yml", ".toml", ".md", ".mjs", ".cjs", ".sh"]);
|
|
30
666
|
var MAX_SCAN_FILE_BYTES = 5 * 1024 * 1024;
|
|
31
|
-
var
|
|
667
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "vendor", "coverage", ".cache"]);
|
|
32
668
|
function collectFiles(dir) {
|
|
33
669
|
const files = [];
|
|
34
670
|
try {
|
|
35
|
-
const entries =
|
|
671
|
+
const entries = readdirSync3(dir);
|
|
36
672
|
for (const entry of entries) {
|
|
37
|
-
if (
|
|
38
|
-
const fullPath =
|
|
39
|
-
const stat =
|
|
673
|
+
if (SKIP_DIRS2.has(entry)) continue;
|
|
674
|
+
const fullPath = join2(dir, entry);
|
|
675
|
+
const stat = statSync2(fullPath);
|
|
40
676
|
if (stat.isDirectory()) {
|
|
41
677
|
files.push(...collectFiles(fullPath));
|
|
42
|
-
} else if (SUPPORTED_EXTENSIONS.has(
|
|
678
|
+
} else if (SUPPORTED_EXTENSIONS.has(extname2(entry).toLowerCase())) {
|
|
43
679
|
files.push(fullPath);
|
|
44
680
|
}
|
|
45
681
|
}
|
|
@@ -61,7 +697,8 @@ function parseArgs(argv) {
|
|
|
61
697
|
help: false,
|
|
62
698
|
version: false,
|
|
63
699
|
policy: "",
|
|
64
|
-
summaryFile: ""
|
|
700
|
+
summaryFile: "",
|
|
701
|
+
profiles: []
|
|
65
702
|
};
|
|
66
703
|
if (result.command === "--help" || result.command === "-h") {
|
|
67
704
|
result.command = "";
|
|
@@ -81,6 +718,8 @@ function parseArgs(argv) {
|
|
|
81
718
|
else if (arg === "--allow-not-assessed") result.allowNotAssessed = true;
|
|
82
719
|
else if (arg === "--policy") result.policy = args[++i] ?? "";
|
|
83
720
|
else if (arg === "--summary-file") result.summaryFile = args[++i] ?? "";
|
|
721
|
+
else if (arg === "--profile") result.profiles.push(args[++i] ?? "");
|
|
722
|
+
else if (arg.startsWith("--profile=")) result.profiles.push(arg.slice("--profile=".length));
|
|
84
723
|
else if (arg === "--help" || arg === "-h") result.help = true;
|
|
85
724
|
else if (arg === "--version" || arg === "-v") result.version = true;
|
|
86
725
|
else if (!arg.startsWith("-")) result.dir = arg;
|
|
@@ -96,9 +735,12 @@ ${c.gray}Execution trust analysis for AI agents${c.reset}
|
|
|
96
735
|
${c.bold}Usage:${c.reset}
|
|
97
736
|
agentverify scan [dir] --key <api-key>
|
|
98
737
|
agentverify scan --file <file> --key <api-key>
|
|
738
|
+
agentverify mcp
|
|
99
739
|
|
|
100
740
|
${c.bold}Commands:${c.reset}
|
|
101
741
|
scan Scan agent files in a directory or a single file
|
|
742
|
+
mcp Run as an MCP server so Claude Code, Cursor, VS Code and other AI
|
|
743
|
+
tools can scan agents while you code (reads AGENTVERIFY_API_KEY)
|
|
102
744
|
|
|
103
745
|
${c.bold}Options:${c.reset}
|
|
104
746
|
--key, -k API key (or set AGENTVERIFY_API_KEY env var)
|
|
@@ -112,6 +754,11 @@ ${c.bold}Options:${c.reset}
|
|
|
112
754
|
in addition to the scanner's own verdict (e.g. financial-agent)
|
|
113
755
|
--summary-file <path>
|
|
114
756
|
Write results as JSON to this path (in addition to any other output)
|
|
757
|
+
--profile <id>
|
|
758
|
+
(alpha) Assess a directory against an assessment profile instead of
|
|
759
|
+
scanning files individually. Supported: ${SUPPORTED_PROFILES.join(", ")}
|
|
760
|
+
Takes a directory; not combinable with --file, --ci, --policy,
|
|
761
|
+
--allow-not-assessed, --markdown or --summary-file.
|
|
115
762
|
--version, -v Print version
|
|
116
763
|
--help, -h Show this help message
|
|
117
764
|
|
|
@@ -131,12 +778,23 @@ ${c.bold}Examples:${c.reset}
|
|
|
131
778
|
${c.gray}# Markdown summary for pull requests${c.reset}
|
|
132
779
|
agentverify scan . --key av_your_key --markdown
|
|
133
780
|
|
|
781
|
+
${c.gray}# Let Claude Code scan your agents (one-time setup)${c.reset}
|
|
782
|
+
claude mcp add agentverify -e AGENTVERIFY_API_KEY=av_your_key -- npx -y agentverify mcp
|
|
783
|
+
|
|
784
|
+
${c.gray}# Assess a skill directory against a profile (alpha); --json prints the service's response${c.reset}
|
|
785
|
+
agentverify scan ./my-skill --profile ${SUPPORTED_PROFILES[0]} --key av_your_key
|
|
786
|
+
|
|
134
787
|
${c.bold}Exit codes (--ci):${c.reset}
|
|
135
788
|
0 All scanned files verified
|
|
136
789
|
1 NOT VERIFIED \u2014 a security/verification check failed
|
|
137
790
|
2 NOT ASSESSED \u2014 insufficient evidence for a verdict (see --allow-not-assessed)
|
|
138
791
|
3 Execution error \u2014 the scan itself could not run (bad key, network, missing file)
|
|
139
792
|
|
|
793
|
+
${c.bold}Exit codes (--profile):${c.reset}
|
|
794
|
+
0 The assessment completed. Control and check statuses (including GAP_IDENTIFIED)
|
|
795
|
+
do not affect the exit code.
|
|
796
|
+
3 Usage or execution error \u2014 nothing was assessed
|
|
797
|
+
|
|
140
798
|
${c.bold}Get your API key:${c.reset}
|
|
141
799
|
https://aimodularity.com/agentverify/dashboard
|
|
142
800
|
|
|
@@ -190,6 +848,8 @@ ${c.bold}Fix first:${c.reset}`);
|
|
|
190
848
|
else write(` No blocking fixes detected. Re-scan after any material change.`);
|
|
191
849
|
if (result.reportInsights?.nextAction) write(`
|
|
192
850
|
${c.bold}Next action:${c.reset} ${result.reportInsights.nextAction}`);
|
|
851
|
+
const lockedFixes = (result.findings ?? []).filter((f) => f.fixLocked).length;
|
|
852
|
+
if (lockedFixes > 0) write(`${c.gray}Step-by-step fixes and corrected code for ${lockedFixes} finding${lockedFixes === 1 ? "" : "s"} are included with Pro: https://aimodularity.com/agentverify/pricing/${c.reset}`);
|
|
193
853
|
const threats = getRelevantThreatCategories(result);
|
|
194
854
|
if (threats.length) {
|
|
195
855
|
write(`
|
|
@@ -201,8 +861,8 @@ ${c.bold}Threat categories:${c.reset}`);
|
|
|
201
861
|
write(`${c.gray}Re-scan:${c.reset} agentverify scan --file ${fileName} --key $AGENTVERIFY_API_KEY
|
|
202
862
|
`);
|
|
203
863
|
}
|
|
204
|
-
function markdownSummary(results,
|
|
205
|
-
const lines = ["# Agent Verify", "", `Files scanned: ${
|
|
864
|
+
function markdownSummary(results, summary2) {
|
|
865
|
+
const lines = ["# Agent Verify", "", `Files scanned: ${summary2.total}`, `Verified: ${summary2.verified}`, `Not verified: ${summary2.notVerified}`, `Not assessed: ${summary2.notAssessed}`, `Errors: ${summary2.errors}`, ""];
|
|
206
866
|
for (const item of results) {
|
|
207
867
|
if (!item.result) {
|
|
208
868
|
lines.push(`## ${item.file}`, "", `Error: ${item.error ?? "Unknown error"}`, "");
|
|
@@ -222,20 +882,20 @@ function markdownSummary(results, summary) {
|
|
|
222
882
|
lines.push(`A2SPA docs: ${A2SPA_DOCS_URL}`);
|
|
223
883
|
return lines.join("\n");
|
|
224
884
|
}
|
|
225
|
-
function writeGithubJobSummary(results,
|
|
885
|
+
function writeGithubJobSummary(results, summary2) {
|
|
226
886
|
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
227
887
|
if (!summaryPath) return;
|
|
228
888
|
try {
|
|
229
|
-
appendFileSync(summaryPath, `${markdownSummary(results,
|
|
889
|
+
appendFileSync(summaryPath, `${markdownSummary(results, summary2)}
|
|
230
890
|
`);
|
|
231
891
|
} catch (e) {
|
|
232
892
|
console.error(`Warning: could not write GitHub job summary: ${e instanceof Error ? e.message : e}`);
|
|
233
893
|
}
|
|
234
894
|
}
|
|
235
|
-
function writeSummaryFile(path, results,
|
|
895
|
+
function writeSummaryFile(path, results, summary2) {
|
|
236
896
|
if (!path) return;
|
|
237
897
|
try {
|
|
238
|
-
writeFileSync(path, JSON.stringify({ results, summary }, null, 2));
|
|
898
|
+
writeFileSync(path, JSON.stringify({ results, summary: summary2 }, null, 2));
|
|
239
899
|
} catch (e) {
|
|
240
900
|
console.error(`Warning: could not write summary file: ${e instanceof Error ? e.message : e}`);
|
|
241
901
|
}
|
|
@@ -278,7 +938,7 @@ ${c.bold}Agent Verify${c.reset} ${c.gray}v${VERSION}${c.reset}`);
|
|
|
278
938
|
files = collectFiles(dir);
|
|
279
939
|
if (files.length === 0) {
|
|
280
940
|
write(`${c.yellow}No agent files found in ${dir}${c.reset}`);
|
|
281
|
-
write(`${c.gray}Supported: .js .ts .py .json .yaml .yml .md${c.reset}
|
|
941
|
+
write(`${c.gray}Supported: .js .jsx .ts .tsx .py .json .yaml .yml .toml .md .sh${c.reset}
|
|
282
942
|
`);
|
|
283
943
|
process.exit(0);
|
|
284
944
|
}
|
|
@@ -293,12 +953,12 @@ ${c.bold}Agent Verify${c.reset} ${c.gray}v${VERSION}${c.reset}`);
|
|
|
293
953
|
const batch = files.slice(i, i + CONCURRENCY);
|
|
294
954
|
const batchResults = await Promise.allSettled(
|
|
295
955
|
batch.map(async (filePath) => {
|
|
296
|
-
const fileStat =
|
|
956
|
+
const fileStat = statSync2(filePath);
|
|
297
957
|
if (fileStat.size > MAX_SCAN_FILE_BYTES) {
|
|
298
958
|
throw new Error(`File is ${(fileStat.size / 1024 / 1024).toFixed(1)}MB, over the ${MAX_SCAN_FILE_BYTES / 1024 / 1024}MB scan limit \u2014 skipped`);
|
|
299
959
|
}
|
|
300
|
-
const content =
|
|
301
|
-
const relPath =
|
|
960
|
+
const content = readFileSync4(filePath, "utf-8");
|
|
961
|
+
const relPath = relative2(args.dir === "." ? process.cwd() : args.dir, filePath);
|
|
302
962
|
const result = await scan(
|
|
303
963
|
{ content, fileName: relPath, policyId: args.policy || void 0 },
|
|
304
964
|
{ apiKey: args.key, apiUrl: API_URL }
|
|
@@ -320,7 +980,7 @@ ${c.bold}Agent Verify${c.reset} ${c.gray}v${VERSION}${c.reset}`);
|
|
|
320
980
|
else notVerified++;
|
|
321
981
|
} else {
|
|
322
982
|
const filePath = batch[j];
|
|
323
|
-
const relPath =
|
|
983
|
+
const relPath = relative2(args.dir, filePath);
|
|
324
984
|
const errMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
325
985
|
if (!args.json) {
|
|
326
986
|
write(` ${c.yellow}\u26A0${c.reset} ${c.yellow}WARNING${c.reset} ${relPath.padEnd(30)} ${c.gray}${errMsg}${c.reset}`);
|
|
@@ -330,11 +990,11 @@ ${c.bold}Agent Verify${c.reset} ${c.gray}v${VERSION}${c.reset}`);
|
|
|
330
990
|
}
|
|
331
991
|
}
|
|
332
992
|
}
|
|
333
|
-
const
|
|
334
|
-
writeGithubJobSummary(jsonResults,
|
|
335
|
-
writeSummaryFile(args.summaryFile, jsonResults,
|
|
993
|
+
const summary2 = { total: files.length, verified, notVerified, notAssessed, errors };
|
|
994
|
+
writeGithubJobSummary(jsonResults, summary2);
|
|
995
|
+
writeSummaryFile(args.summaryFile, jsonResults, summary2);
|
|
336
996
|
if (args.json) {
|
|
337
|
-
write(JSON.stringify({ results: jsonResults, summary }, null, 2));
|
|
997
|
+
write(JSON.stringify({ results: jsonResults, summary: summary2 }, null, 2));
|
|
338
998
|
if (errors > 0) process.exit(EXIT_EXECUTION_ERROR);
|
|
339
999
|
if (args.ci) {
|
|
340
1000
|
if (notVerified > 0) process.exit(EXIT_NOT_VERIFIED);
|
|
@@ -343,7 +1003,7 @@ ${c.bold}Agent Verify${c.reset} ${c.gray}v${VERSION}${c.reset}`);
|
|
|
343
1003
|
return;
|
|
344
1004
|
}
|
|
345
1005
|
if (args.markdown) {
|
|
346
|
-
write(markdownSummary(jsonResults,
|
|
1006
|
+
write(markdownSummary(jsonResults, summary2));
|
|
347
1007
|
if (errors > 0) process.exit(EXIT_EXECUTION_ERROR);
|
|
348
1008
|
if (args.ci) {
|
|
349
1009
|
if (notVerified > 0) process.exit(EXIT_NOT_VERIFIED);
|
|
@@ -373,6 +1033,89 @@ ${c.gray}No dashboard report was saved. Check API key access and retry if you ne
|
|
|
373
1033
|
if (notAssessed > 0 && !args.allowNotAssessed) process.exit(EXIT_NOT_ASSESSED);
|
|
374
1034
|
}
|
|
375
1035
|
}
|
|
1036
|
+
var PROFILE_TIMEOUT_MS = 6e4;
|
|
1037
|
+
function profileUsageError(message) {
|
|
1038
|
+
console.error(`
|
|
1039
|
+
${c.red}Error:${c.reset} ${message}`);
|
|
1040
|
+
console.error(`Run ${c.cyan}agentverify --help${c.reset} for usage
|
|
1041
|
+
`);
|
|
1042
|
+
process.exitCode = EXIT_EXECUTION_ERROR;
|
|
1043
|
+
}
|
|
1044
|
+
function profileFailure(json, message, detail = {}) {
|
|
1045
|
+
if (json) write(JSON.stringify({ error: message, ...detail }, null, 2));
|
|
1046
|
+
else console.error(`
|
|
1047
|
+
${c.red}Error:${c.reset} ${safeText(message)}
|
|
1048
|
+
`);
|
|
1049
|
+
process.exitCode = EXIT_EXECUTION_ERROR;
|
|
1050
|
+
}
|
|
1051
|
+
async function runProfile(args) {
|
|
1052
|
+
if (args.profiles.length > 1) return profileUsageError("--profile was given more than once.");
|
|
1053
|
+
const profile = args.profiles[0];
|
|
1054
|
+
if (profile === "") return profileUsageError(`--profile requires a value. Supported profiles: ${SUPPORTED_PROFILES.join(", ")}`);
|
|
1055
|
+
if (!SUPPORTED_PROFILES.includes(profile)) {
|
|
1056
|
+
return profileUsageError(`Unknown profile ${JSON.stringify(safeText(profile))}. Supported profiles: ${SUPPORTED_PROFILES.join(", ")}`);
|
|
1057
|
+
}
|
|
1058
|
+
const conflicts = [];
|
|
1059
|
+
if (args.file) conflicts.push("--file");
|
|
1060
|
+
if (args.ci) conflicts.push("--ci");
|
|
1061
|
+
if (args.allowNotAssessed) conflicts.push("--allow-not-assessed");
|
|
1062
|
+
if (args.policy) conflicts.push("--policy");
|
|
1063
|
+
if (args.markdown) conflicts.push("--markdown");
|
|
1064
|
+
if (args.summaryFile) conflicts.push("--summary-file");
|
|
1065
|
+
if (conflicts.length > 0) return profileUsageError(`--profile cannot be combined with ${conflicts.join(", ")}. A profile assessment takes a directory and reports its own result.`);
|
|
1066
|
+
if (!args.key) {
|
|
1067
|
+
console.error(`
|
|
1068
|
+
${c.red}Error:${c.reset} API key required
|
|
1069
|
+
`);
|
|
1070
|
+
console.error(` Set with --key av_your_key or AGENTVERIFY_API_KEY env var.`);
|
|
1071
|
+
console.error(` Get your key at https://aimodularity.com/agentverify/dashboard/
|
|
1072
|
+
`);
|
|
1073
|
+
process.exitCode = EXIT_EXECUTION_ERROR;
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
let built;
|
|
1077
|
+
try {
|
|
1078
|
+
built = buildPackage(args.dir);
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
return profileFailure(args.json, e instanceof PackageError ? e.message : `Could not read the package: ${e instanceof Error ? e.message : String(e)}`);
|
|
1081
|
+
}
|
|
1082
|
+
if (args.json && built.skipped.length > 0) {
|
|
1083
|
+
console.error(`Note: ${built.skipped.length} entries were skipped from the package and not sent: ${built.skipped.slice(0, 20).map((s) => `${displayPath(s.path)} (${s.reason})`).join(", ")}${built.skipped.length > 20 ? `, and ${built.skipped.length - 20} more` : ""}`);
|
|
1084
|
+
}
|
|
1085
|
+
const result = await postProfile(API_URL, args.key, serializeRequest(profile, built.files), PROFILE_TIMEOUT_MS);
|
|
1086
|
+
if (result.kind === "failure") return profileFailure(args.json, result.message);
|
|
1087
|
+
const body = result.json;
|
|
1088
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1089
|
+
if (result.status !== 200) {
|
|
1090
|
+
if (isObject(body) && typeof body.error === "string") {
|
|
1091
|
+
if (args.json) {
|
|
1092
|
+
write(JSON.stringify(body, null, 2));
|
|
1093
|
+
} else {
|
|
1094
|
+
console.error(`
|
|
1095
|
+
${c.red}Error:${c.reset} ${safeText(body.error)} (HTTP ${result.status})`);
|
|
1096
|
+
const rejection = body.rejection;
|
|
1097
|
+
if (isObject(rejection)) console.error(` rejection: ${safeText(String(rejection.code ?? ""))}: ${safeText(String(rejection.message ?? ""))}${typeof rejection.path === "string" ? ` (${JSON.stringify(safeText(rejection.path))})` : ""}`);
|
|
1098
|
+
if (Array.isArray(body.supportedProfiles)) console.error(` supported profiles: ${body.supportedProfiles.map((p) => safeText(String(p))).join(", ")}`);
|
|
1099
|
+
console.error("");
|
|
1100
|
+
}
|
|
1101
|
+
process.exitCode = EXIT_EXECUTION_ERROR;
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
return profileFailure(args.json, `Unexpected response from the service (HTTP ${result.status})`, { status: result.status });
|
|
1105
|
+
}
|
|
1106
|
+
if (!isObject(body) || typeof body.profile !== "string" || !isObject(body.assessment)) {
|
|
1107
|
+
return profileFailure(args.json, "The service returned a response that is not a profile assessment", { status: result.status });
|
|
1108
|
+
}
|
|
1109
|
+
if (args.json) {
|
|
1110
|
+
write(JSON.stringify(body, null, 2));
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const schema = body.assessment.schemaVersion;
|
|
1114
|
+
if (schema !== SUPPORTED_ASSESSMENT_SCHEMA) {
|
|
1115
|
+
return profileFailure(false, `This CLI prints assessment schema ${SUPPORTED_ASSESSMENT_SCHEMA}, but the service returned ${JSON.stringify(safeText(String(schema)))}. Update the CLI, or use --json to receive the raw response.`);
|
|
1116
|
+
}
|
|
1117
|
+
for (const line of formatAssessment(body, built.skipped)) write(line);
|
|
1118
|
+
}
|
|
376
1119
|
async function main() {
|
|
377
1120
|
const args = parseArgs(process.argv);
|
|
378
1121
|
if (args.version) {
|
|
@@ -383,8 +1126,13 @@ async function main() {
|
|
|
383
1126
|
printHelp();
|
|
384
1127
|
process.exit(EXIT_OK);
|
|
385
1128
|
}
|
|
1129
|
+
if (args.command === "mcp") {
|
|
1130
|
+
await runMcpServer({ version: VERSION, apiKey: args.key, apiUrl: API_URL });
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
386
1133
|
if (args.command === "scan") {
|
|
387
|
-
await
|
|
1134
|
+
if (args.profiles.length > 0) await runProfile(args);
|
|
1135
|
+
else await runScan(args);
|
|
388
1136
|
} else {
|
|
389
1137
|
console.error(`
|
|
390
1138
|
${c.red}Unknown command: ${args.command}${c.reset}`);
|