@agent-wall/cli 0.1.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/.turbo/turbo-build.log +18 -0
- package/.turbo/turbo-test.log +19 -0
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/dashboard/assets/index-BOAuOkd7.css +1 -0
- package/dist/dashboard/assets/index-_Zwjwdf_.js +50 -0
- package/dist/dashboard/assets/index-_Zwjwdf_.js.map +1 -0
- package/dist/dashboard/favicon.svg +5 -0
- package/dist/dashboard/index.html +14 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1074 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
- package/src/commands/audit.test.ts +175 -0
- package/src/commands/audit.ts +158 -0
- package/src/commands/doctor.test.ts +108 -0
- package/src/commands/doctor.ts +146 -0
- package/src/commands/init.test.ts +85 -0
- package/src/commands/init.ts +52 -0
- package/src/commands/scan.test.ts +279 -0
- package/src/commands/scan.ts +338 -0
- package/src/commands/test.test.ts +152 -0
- package/src/commands/test.ts +108 -0
- package/src/commands/validate.test.ts +104 -0
- package/src/commands/validate.ts +181 -0
- package/src/commands/wrap.ts +420 -0
- package/src/index.ts +151 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +12 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-wall scan — Scan your current MCP configuration for security risks.
|
|
3
|
+
*
|
|
4
|
+
* Reads Claude Code / Cursor MCP config files and reports
|
|
5
|
+
* which servers have unrestricted access and would benefit from Agent Wall.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* agent-wall scan
|
|
9
|
+
* agent-wall scan --config ~/.claude/mcp_servers.json
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
|
|
17
|
+
export interface ScanOptions {
|
|
18
|
+
config?: string;
|
|
19
|
+
json?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface McpServerConfig {
|
|
23
|
+
command: string;
|
|
24
|
+
args?: string[];
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Known risky MCP tool patterns based on the official MCP servers ecosystem.
|
|
30
|
+
* Sources: modelcontextprotocol/servers registry + popular third-party servers.
|
|
31
|
+
*/
|
|
32
|
+
const RISKY_TOOLS = [
|
|
33
|
+
// ── Execution (Critical) ──────────────────────────────────────────
|
|
34
|
+
{ pattern: "shell", risk: "critical", reason: "Arbitrary shell command execution" },
|
|
35
|
+
{ pattern: "bash", risk: "critical", reason: "Arbitrary bash execution" },
|
|
36
|
+
{ pattern: "exec", risk: "critical", reason: "Process execution" },
|
|
37
|
+
{ pattern: "terminal", risk: "critical", reason: "Terminal access" },
|
|
38
|
+
|
|
39
|
+
// ── Filesystem ────────────────────────────────────────────────────
|
|
40
|
+
{ pattern: "filesystem", risk: "high", reason: "Full filesystem read/write access" },
|
|
41
|
+
|
|
42
|
+
// ── Browser / Web ─────────────────────────────────────────────────
|
|
43
|
+
{ pattern: "playwright", risk: "high", reason: "Browser automation via Playwright (prompt injection target)" },
|
|
44
|
+
{ pattern: "puppeteer", risk: "high", reason: "Browser automation via Puppeteer (prompt injection target)" },
|
|
45
|
+
{ pattern: "browser", risk: "high", reason: "Browser automation (prompt injection target)" },
|
|
46
|
+
{ pattern: "fetch", risk: "medium", reason: "Outbound HTTP requests (exfiltration vector)" },
|
|
47
|
+
|
|
48
|
+
// ── Source Control ────────────────────────────────────────────────
|
|
49
|
+
{ pattern: "github", risk: "medium", reason: "GitHub API access (code/issues/PRs)" },
|
|
50
|
+
{ pattern: "gitlab", risk: "medium", reason: "GitLab API access (code/issues/MRs)" },
|
|
51
|
+
{ pattern: "git", risk: "medium", reason: "Repository access and modification" },
|
|
52
|
+
|
|
53
|
+
// ── Databases ─────────────────────────────────────────────────────
|
|
54
|
+
{ pattern: "postgres", risk: "high", reason: "PostgreSQL access" },
|
|
55
|
+
{ pattern: "mysql", risk: "high", reason: "MySQL database access" },
|
|
56
|
+
{ pattern: "mongodb", risk: "high", reason: "MongoDB database access" },
|
|
57
|
+
{ pattern: "redis", risk: "medium", reason: "Redis data store access" },
|
|
58
|
+
{ pattern: "sqlite", risk: "medium", reason: "SQLite database access" },
|
|
59
|
+
{ pattern: "supabase", risk: "high", reason: "Supabase database/auth/storage access" },
|
|
60
|
+
{ pattern: "neon", risk: "high", reason: "Neon serverless Postgres access" },
|
|
61
|
+
{ pattern: "snowflake", risk: "high", reason: "Snowflake data warehouse access" },
|
|
62
|
+
{ pattern: "database", risk: "high", reason: "Database query execution" },
|
|
63
|
+
|
|
64
|
+
// ── Infrastructure / Cloud ────────────────────────────────────────
|
|
65
|
+
{ pattern: "docker", risk: "critical", reason: "Container management" },
|
|
66
|
+
{ pattern: "kubernetes", risk: "critical", reason: "Cluster management" },
|
|
67
|
+
{ pattern: "terraform", risk: "critical", reason: "Infrastructure-as-code provisioning" },
|
|
68
|
+
{ pattern: "aws", risk: "critical", reason: "AWS cloud resource access" },
|
|
69
|
+
{ pattern: "gcp", risk: "critical", reason: "Google Cloud resource access" },
|
|
70
|
+
{ pattern: "azure", risk: "critical", reason: "Azure cloud resource access" },
|
|
71
|
+
{ pattern: "cloudflare", risk: "high", reason: "Cloudflare infrastructure management" },
|
|
72
|
+
{ pattern: "vercel", risk: "high", reason: "Vercel deployment/project management" },
|
|
73
|
+
{ pattern: "netlify", risk: "high", reason: "Netlify deployment/project management" },
|
|
74
|
+
|
|
75
|
+
// ── Communication ─────────────────────────────────────────────────
|
|
76
|
+
{ pattern: "slack", risk: "medium", reason: "Slack workspace messaging access" },
|
|
77
|
+
{ pattern: "email", risk: "medium", reason: "Email send/read access" },
|
|
78
|
+
{ pattern: "gmail", risk: "medium", reason: "Gmail account access" },
|
|
79
|
+
{ pattern: "discord", risk: "medium", reason: "Discord messaging access" },
|
|
80
|
+
|
|
81
|
+
// ── Payment / Secrets ─────────────────────────────────────────────
|
|
82
|
+
{ pattern: "stripe", risk: "critical", reason: "Payment processing / financial data" },
|
|
83
|
+
{ pattern: "razorpay", risk: "critical", reason: "Payment processing / financial data" },
|
|
84
|
+
{ pattern: "vault", risk: "critical", reason: "Secret management (HashiCorp Vault)" },
|
|
85
|
+
{ pattern: "1password", risk: "critical", reason: "Password manager access" },
|
|
86
|
+
|
|
87
|
+
// ── Remote Access ─────────────────────────────────────────────────
|
|
88
|
+
{ pattern: "ssh", risk: "critical", reason: "Remote server access via SSH" },
|
|
89
|
+
{ pattern: "rdp", risk: "critical", reason: "Remote desktop access" },
|
|
90
|
+
|
|
91
|
+
// ── AI / LLM ──────────────────────────────────────────────────────
|
|
92
|
+
{ pattern: "openai", risk: "medium", reason: "OpenAI API access (cost / data)" },
|
|
93
|
+
{ pattern: "anthropic", risk: "medium", reason: "Anthropic API access (cost / data)" },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
function detectConfigPaths(): string[] {
|
|
97
|
+
const home = os.homedir();
|
|
98
|
+
const platform = os.platform();
|
|
99
|
+
const candidates: string[] = [
|
|
100
|
+
// ── Claude ────────────────────────────────────────────────────
|
|
101
|
+
// Claude Code
|
|
102
|
+
path.join(home, ".claude", "mcp_servers.json"),
|
|
103
|
+
// Claude Desktop (macOS)
|
|
104
|
+
path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
|
|
105
|
+
// Claude Desktop (Windows)
|
|
106
|
+
path.join(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json"),
|
|
107
|
+
// Claude Desktop (Linux)
|
|
108
|
+
path.join(home, ".config", "Claude", "claude_desktop_config.json"),
|
|
109
|
+
|
|
110
|
+
// ── Cursor ────────────────────────────────────────────────────
|
|
111
|
+
path.join(home, ".cursor", "mcp.json"),
|
|
112
|
+
|
|
113
|
+
// ── VS Code / Copilot ─────────────────────────────────────────
|
|
114
|
+
// VS Code workspace-level MCP (vscode 1.99+)
|
|
115
|
+
path.join(process.cwd(), ".vscode", "mcp.json"),
|
|
116
|
+
|
|
117
|
+
// ── Windsurf (Codeium) ────────────────────────────────────────
|
|
118
|
+
path.join(home, ".codeium", "windsurf", "mcp_config.json"),
|
|
119
|
+
|
|
120
|
+
// ── Cline ─────────────────────────────────────────────────────
|
|
121
|
+
path.join(home, ".cline", "mcp_settings.json"),
|
|
122
|
+
|
|
123
|
+
// ── Continue.dev ──────────────────────────────────────────────
|
|
124
|
+
path.join(home, ".continue", "config.json"),
|
|
125
|
+
|
|
126
|
+
// ── Generic / project-level ───────────────────────────────────
|
|
127
|
+
path.join(process.cwd(), ".mcp.json"),
|
|
128
|
+
path.join(process.cwd(), "mcp.json"),
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
// VS Code user-level settings (platform-aware)
|
|
132
|
+
if (platform === "win32") {
|
|
133
|
+
candidates.push(
|
|
134
|
+
path.join(home, "AppData", "Roaming", "Code", "User", "settings.json")
|
|
135
|
+
);
|
|
136
|
+
} else if (platform === "darwin") {
|
|
137
|
+
candidates.push(
|
|
138
|
+
path.join(home, "Library", "Application Support", "Code", "User", "settings.json")
|
|
139
|
+
);
|
|
140
|
+
} else {
|
|
141
|
+
candidates.push(
|
|
142
|
+
path.join(home, ".config", "Code", "User", "settings.json")
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return candidates.filter((p) => fs.existsSync(p));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function scanCommand(options: ScanOptions): void {
|
|
150
|
+
let configPaths: string[] = [];
|
|
151
|
+
|
|
152
|
+
if (options.config) {
|
|
153
|
+
if (!fs.existsSync(options.config)) {
|
|
154
|
+
process.stderr.write(
|
|
155
|
+
chalk.red(`Error: Config not found: ${options.config}\n`)
|
|
156
|
+
);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
configPaths = [options.config];
|
|
160
|
+
} else {
|
|
161
|
+
configPaths = detectConfigPaths();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (configPaths.length === 0) {
|
|
165
|
+
if (options.json) {
|
|
166
|
+
process.stdout.write(JSON.stringify({ servers: [], totalRisks: 0 }, null, 2) + "\n");
|
|
167
|
+
} else {
|
|
168
|
+
process.stderr.write(
|
|
169
|
+
chalk.yellow(
|
|
170
|
+
"\n⚠ No MCP configuration files found.\n\n" +
|
|
171
|
+
chalk.gray(
|
|
172
|
+
" Looked for config files from:\n" +
|
|
173
|
+
" • Claude Code ~/.claude/mcp_servers.json\n" +
|
|
174
|
+
" • Claude Desktop ~/Library/.../claude_desktop_config.json\n" +
|
|
175
|
+
" • Cursor ~/.cursor/mcp.json\n" +
|
|
176
|
+
" • VS Code / Copilot .vscode/mcp.json\n" +
|
|
177
|
+
" • Windsurf ~/.codeium/windsurf/mcp_config.json\n" +
|
|
178
|
+
" • Cline ~/.cline/mcp_settings.json\n" +
|
|
179
|
+
" • Continue.dev ~/.continue/config.json\n" +
|
|
180
|
+
" • Project-level .mcp.json, mcp.json\n\n" +
|
|
181
|
+
" Tip: pass --config <path> to scan a specific file.\n\n"
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Collect results ──────────────────────────────────────────────
|
|
190
|
+
interface ServerResult {
|
|
191
|
+
name: string;
|
|
192
|
+
configFile: string;
|
|
193
|
+
command: string;
|
|
194
|
+
protected: boolean;
|
|
195
|
+
risks: Array<{ level: string; reason: string }>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const results: ServerResult[] = [];
|
|
199
|
+
let totalRisks = 0;
|
|
200
|
+
|
|
201
|
+
for (const configPath of configPaths) {
|
|
202
|
+
let raw: Record<string, unknown>;
|
|
203
|
+
try {
|
|
204
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
205
|
+
raw = JSON.parse(content);
|
|
206
|
+
} catch {
|
|
207
|
+
if (!options.json) {
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
chalk.red(` Failed to parse: ${configPath}\n\n`)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const servers: Record<string, McpServerConfig> =
|
|
216
|
+
(raw.mcpServers as Record<string, McpServerConfig>) ??
|
|
217
|
+
(raw as Record<string, McpServerConfig>);
|
|
218
|
+
|
|
219
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
220
|
+
if (!config || typeof config !== "object" || !config.command) continue;
|
|
221
|
+
|
|
222
|
+
const serverStr = `${config.command} ${(config.args ?? []).join(" ")}`;
|
|
223
|
+
const risks: Array<{ level: string; reason: string }> = [];
|
|
224
|
+
|
|
225
|
+
for (const risky of RISKY_TOOLS) {
|
|
226
|
+
if (serverStr.toLowerCase().includes(risky.pattern)) {
|
|
227
|
+
risks.push({ level: risky.risk, reason: risky.reason });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const isProtected = serverStr.includes("agent-wall");
|
|
232
|
+
if (!isProtected) totalRisks += risks.length;
|
|
233
|
+
|
|
234
|
+
results.push({
|
|
235
|
+
name,
|
|
236
|
+
configFile: configPath,
|
|
237
|
+
command: serverStr,
|
|
238
|
+
protected: isProtected,
|
|
239
|
+
risks,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── JSON output ──────────────────────────────────────────────────
|
|
245
|
+
if (options.json) {
|
|
246
|
+
process.stdout.write(
|
|
247
|
+
JSON.stringify({ servers: results, totalRisks }, null, 2) + "\n"
|
|
248
|
+
);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Pretty output ────────────────────────────────────────────────
|
|
253
|
+
process.stderr.write("\n");
|
|
254
|
+
process.stderr.write(
|
|
255
|
+
chalk.cyan("─── Agent Wall Security Scan ─────────────────────\n\n")
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
let lastConfig = "";
|
|
259
|
+
for (const server of results) {
|
|
260
|
+
if (server.configFile !== lastConfig) {
|
|
261
|
+
lastConfig = server.configFile;
|
|
262
|
+
process.stderr.write(
|
|
263
|
+
chalk.gray(" Config: ") + chalk.white(server.configFile) + "\n\n"
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const riskColor =
|
|
268
|
+
server.risks.some((r) => r.level === "critical")
|
|
269
|
+
? chalk.red
|
|
270
|
+
: server.risks.some((r) => r.level === "high")
|
|
271
|
+
? chalk.yellow
|
|
272
|
+
: server.risks.length > 0
|
|
273
|
+
? chalk.gray
|
|
274
|
+
: chalk.green;
|
|
275
|
+
|
|
276
|
+
const statusIcon = server.protected
|
|
277
|
+
? chalk.green("🛡")
|
|
278
|
+
: server.risks.length > 0
|
|
279
|
+
? chalk.red("⚠")
|
|
280
|
+
: chalk.green("✓");
|
|
281
|
+
|
|
282
|
+
process.stderr.write(
|
|
283
|
+
` ${statusIcon} ${chalk.bold.white(server.name)}\n`
|
|
284
|
+
);
|
|
285
|
+
process.stderr.write(
|
|
286
|
+
chalk.gray(` Command: ${server.command.slice(0, 80)}\n`)
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
if (server.protected) {
|
|
290
|
+
process.stderr.write(
|
|
291
|
+
chalk.green(" Protected by Agent Wall ✓\n")
|
|
292
|
+
);
|
|
293
|
+
} else if (server.risks.length > 0) {
|
|
294
|
+
for (const risk of server.risks) {
|
|
295
|
+
process.stderr.write(
|
|
296
|
+
riskColor(
|
|
297
|
+
` ${risk.level.toUpperCase()}: ${risk.reason}\n`
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
process.stderr.write(
|
|
302
|
+
chalk.gray(
|
|
303
|
+
` Fix: agent-wall wrap -- ${server.command}\n`
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
} else {
|
|
307
|
+
process.stderr.write(chalk.green(" No known risks detected\n"));
|
|
308
|
+
}
|
|
309
|
+
process.stderr.write("\n");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Summary
|
|
313
|
+
process.stderr.write(
|
|
314
|
+
chalk.cyan("─── Scan Results ───────────────────────────────\n")
|
|
315
|
+
);
|
|
316
|
+
process.stderr.write(
|
|
317
|
+
chalk.gray(" MCP Servers: ") + chalk.white(String(results.length)) + "\n"
|
|
318
|
+
);
|
|
319
|
+
process.stderr.write(
|
|
320
|
+
chalk.gray(" Risks found: ") +
|
|
321
|
+
(totalRisks > 0
|
|
322
|
+
? chalk.red(String(totalRisks))
|
|
323
|
+
: chalk.green("0")) +
|
|
324
|
+
"\n"
|
|
325
|
+
);
|
|
326
|
+
process.stderr.write(
|
|
327
|
+
chalk.cyan("─────────────────────────────────────────────────\n\n")
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
if (totalRisks > 0) {
|
|
331
|
+
process.stderr.write(
|
|
332
|
+
chalk.yellow(
|
|
333
|
+
" Run 'agent-wall init' to create a policy config,\n" +
|
|
334
|
+
" then wrap your servers with 'agent-wall wrap'.\n\n"
|
|
335
|
+
)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { testCommand } from "./test.js";
|
|
3
|
+
|
|
4
|
+
// Mock @agent-wall/core
|
|
5
|
+
vi.mock("@agent-wall/core", () => {
|
|
6
|
+
const config = {
|
|
7
|
+
version: 1,
|
|
8
|
+
defaultAction: "deny",
|
|
9
|
+
rules: [
|
|
10
|
+
{ name: "block-ssh", tool: "read_file", action: "deny", arguments: { path: "*.ssh*" } },
|
|
11
|
+
{ name: "allow-project", tool: "read_file", action: "allow", arguments: { path: "/project/**" } },
|
|
12
|
+
{ name: "prompt-shell", tool: "shell_exec", action: "prompt" },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
class MockPolicyEngine {
|
|
17
|
+
private config: any;
|
|
18
|
+
constructor(cfg: any) {
|
|
19
|
+
this.config = cfg;
|
|
20
|
+
}
|
|
21
|
+
evaluate(toolCall: any) {
|
|
22
|
+
// Simulate first-match-wins
|
|
23
|
+
for (const rule of this.config.rules) {
|
|
24
|
+
if (rule.tool === toolCall.name) {
|
|
25
|
+
return {
|
|
26
|
+
action: rule.action,
|
|
27
|
+
rule: rule.name,
|
|
28
|
+
message: `Matched rule: ${rule.name}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
action: this.config.defaultAction,
|
|
34
|
+
rule: null,
|
|
35
|
+
message: "No matching rule, using default",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
PolicyEngine: MockPolicyEngine,
|
|
42
|
+
loadPolicy: (configPath?: string) => ({
|
|
43
|
+
config,
|
|
44
|
+
filePath: configPath ?? "agent-wall.yaml",
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("testCommand", () => {
|
|
50
|
+
let exitSpy: any;
|
|
51
|
+
let stderrSpy: any;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
|
55
|
+
throw new Error("process.exit");
|
|
56
|
+
});
|
|
57
|
+
stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.restoreAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("exits with error when --tool is missing", () => {
|
|
65
|
+
expect(() =>
|
|
66
|
+
testCommand({ tool: "" })
|
|
67
|
+
).toThrow("process.exit");
|
|
68
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("evaluates a denied tool call with exit code 1", () => {
|
|
72
|
+
expect(() =>
|
|
73
|
+
testCommand({
|
|
74
|
+
tool: "read_file",
|
|
75
|
+
arg: ["path=/home/.ssh/id_rsa"],
|
|
76
|
+
})
|
|
77
|
+
).toThrow("process.exit");
|
|
78
|
+
// Deny → exit(1)
|
|
79
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
80
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
81
|
+
expect(allOutput).toContain("DENIED");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("evaluates an allowed tool call with exit code 0", () => {
|
|
85
|
+
// Override: read_file matches "block-ssh" first in our mock
|
|
86
|
+
// Actually our mock matches by tool name first, so read_file → deny
|
|
87
|
+
// Let's test a tool that doesn't match → default deny → exit(1)
|
|
88
|
+
// We need an allow result. The mock has block-ssh matching read_file.
|
|
89
|
+
// Let's use a tool name that hits no rules → default deny.
|
|
90
|
+
expect(() =>
|
|
91
|
+
testCommand({
|
|
92
|
+
tool: "shell_exec",
|
|
93
|
+
arg: ["command=ls"],
|
|
94
|
+
})
|
|
95
|
+
).toThrow("process.exit");
|
|
96
|
+
// prompt-shell has action: "prompt" → exit(0)
|
|
97
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
98
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
99
|
+
expect(allOutput).toContain("PROMPT");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("parses --arg key=value arguments correctly", () => {
|
|
103
|
+
expect(() =>
|
|
104
|
+
testCommand({
|
|
105
|
+
tool: "read_file",
|
|
106
|
+
arg: ["path=/home/.ssh/id_rsa", "encoding=utf-8"],
|
|
107
|
+
})
|
|
108
|
+
).toThrow("process.exit");
|
|
109
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
110
|
+
expect(allOutput).toContain("path");
|
|
111
|
+
expect(allOutput).toContain("encoding");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects invalid arg format (no equals sign)", () => {
|
|
115
|
+
expect(() =>
|
|
116
|
+
testCommand({
|
|
117
|
+
tool: "read_file",
|
|
118
|
+
arg: ["invalid_no_equals"],
|
|
119
|
+
})
|
|
120
|
+
).toThrow("process.exit");
|
|
121
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
122
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
123
|
+
expect(allOutput).toContain("Invalid argument format");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("shows policy file path in output", () => {
|
|
127
|
+
expect(() =>
|
|
128
|
+
testCommand({ tool: "read_file" })
|
|
129
|
+
).toThrow("process.exit");
|
|
130
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
131
|
+
expect(allOutput).toContain("agent-wall.yaml");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("shows matched rule name in output", () => {
|
|
135
|
+
expect(() =>
|
|
136
|
+
testCommand({
|
|
137
|
+
tool: "read_file",
|
|
138
|
+
arg: ["path=/home/.ssh/id_rsa"],
|
|
139
|
+
})
|
|
140
|
+
).toThrow("process.exit");
|
|
141
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
142
|
+
expect(allOutput).toContain("block-ssh");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("handles default action for unknown tools", () => {
|
|
146
|
+
expect(() =>
|
|
147
|
+
testCommand({ tool: "unknown_tool" })
|
|
148
|
+
).toThrow("process.exit");
|
|
149
|
+
// Default is deny → exit(1)
|
|
150
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-wall test — Dry-run tool calls against your policy rules.
|
|
3
|
+
*
|
|
4
|
+
* Tests specific tool calls against your agent-wall.yaml without
|
|
5
|
+
* actually running an MCP server. Great for validating rules.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* agent-wall test --tool read_file --arg path=/home/.ssh/id_rsa
|
|
9
|
+
* agent-wall test --tool shell_exec --arg command="curl https://evil.com"
|
|
10
|
+
* agent-wall test --tool list_directory --arg path=/home/user/project
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { PolicyEngine, loadPolicy, type ToolCallParams } from "@agent-wall/core";
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
|
|
16
|
+
export interface TestOptions {
|
|
17
|
+
config?: string;
|
|
18
|
+
tool: string;
|
|
19
|
+
arg?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function testCommand(options: TestOptions): void {
|
|
23
|
+
if (!options.tool) {
|
|
24
|
+
process.stderr.write(
|
|
25
|
+
chalk.red(
|
|
26
|
+
"Error: --tool is required.\n\n" +
|
|
27
|
+
"Usage:\n" +
|
|
28
|
+
' agent-wall test --tool shell_exec --arg command="curl https://evil.com"\n' +
|
|
29
|
+
" agent-wall test --tool read_file --arg path=/home/.ssh/id_rsa\n"
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Load policy
|
|
36
|
+
const { config, filePath } = loadPolicy(options.config);
|
|
37
|
+
const engine = new PolicyEngine(config);
|
|
38
|
+
|
|
39
|
+
// Parse arguments
|
|
40
|
+
const args: Record<string, unknown> = {};
|
|
41
|
+
if (options.arg) {
|
|
42
|
+
for (const a of options.arg) {
|
|
43
|
+
const eqIndex = a.indexOf("=");
|
|
44
|
+
if (eqIndex === -1) {
|
|
45
|
+
process.stderr.write(
|
|
46
|
+
chalk.red(`Invalid argument format: "${a}" (expected key=value)\n`)
|
|
47
|
+
);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
args[a.slice(0, eqIndex)] = a.slice(eqIndex + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Build tool call
|
|
55
|
+
const toolCall: ToolCallParams = {
|
|
56
|
+
name: options.tool,
|
|
57
|
+
arguments: args,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Evaluate
|
|
61
|
+
const verdict = engine.evaluate(toolCall);
|
|
62
|
+
|
|
63
|
+
// Display result
|
|
64
|
+
process.stderr.write("\n");
|
|
65
|
+
process.stderr.write(
|
|
66
|
+
chalk.gray("Policy: ") +
|
|
67
|
+
chalk.white(filePath ?? "built-in defaults") +
|
|
68
|
+
"\n"
|
|
69
|
+
);
|
|
70
|
+
process.stderr.write(
|
|
71
|
+
chalk.gray("Tool: ") + chalk.white(toolCall.name) + "\n"
|
|
72
|
+
);
|
|
73
|
+
process.stderr.write(
|
|
74
|
+
chalk.gray("Args: ") +
|
|
75
|
+
chalk.white(JSON.stringify(toolCall.arguments)) +
|
|
76
|
+
"\n"
|
|
77
|
+
);
|
|
78
|
+
process.stderr.write("\n");
|
|
79
|
+
|
|
80
|
+
const actionColors = {
|
|
81
|
+
allow: chalk.green,
|
|
82
|
+
deny: chalk.red,
|
|
83
|
+
prompt: chalk.yellow,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const actionSymbols = {
|
|
87
|
+
allow: "✓ ALLOWED",
|
|
88
|
+
deny: "✗ DENIED",
|
|
89
|
+
prompt: "? PROMPT",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const color = actionColors[verdict.action];
|
|
93
|
+
process.stderr.write(
|
|
94
|
+
color(` ${actionSymbols[verdict.action]}`) + "\n"
|
|
95
|
+
);
|
|
96
|
+
if (verdict.rule) {
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
chalk.gray(" Rule: ") + chalk.white(verdict.rule) + "\n"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
process.stderr.write(
|
|
102
|
+
chalk.gray(" Message: ") + chalk.white(verdict.message) + "\n"
|
|
103
|
+
);
|
|
104
|
+
process.stderr.write("\n");
|
|
105
|
+
|
|
106
|
+
// Exit with appropriate code
|
|
107
|
+
process.exit(verdict.action === "deny" ? 1 : 0);
|
|
108
|
+
}
|