@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.
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import { doctorCommand } from "./doctor.js";
5
+
6
+ vi.mock("node:fs");
7
+ vi.mock("node:os");
8
+ vi.mock("@agent-wall/core", () => {
9
+ class MockPolicyEngine {
10
+ evaluate() {
11
+ return { action: "allow", rule: null, message: "Default allow" };
12
+ }
13
+ }
14
+ return {
15
+ loadPolicy: (_configPath?: string) => ({
16
+ config: {
17
+ rules: [{ name: "test-rule", tools: ["*"], action: "allow" }],
18
+ defaultAction: "deny",
19
+ },
20
+ filePath: _configPath ?? "/test/agent-wall.yaml",
21
+ }),
22
+ PolicyEngine: MockPolicyEngine,
23
+ };
24
+ });
25
+
26
+ const mockFs = vi.mocked(fs);
27
+ const mockOs = vi.mocked(os);
28
+
29
+ describe("doctorCommand", () => {
30
+ let stderrSpy: any;
31
+
32
+ beforeEach(() => {
33
+ stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
34
+ mockOs.homedir.mockReturnValue("/home/testuser");
35
+ mockOs.platform.mockReturnValue("linux" as NodeJS.Platform);
36
+ mockFs.existsSync.mockReturnValue(false);
37
+ });
38
+
39
+ afterEach(() => {
40
+ vi.restoreAllMocks();
41
+ });
42
+
43
+ it("shows doctor header", () => {
44
+ doctorCommand({});
45
+
46
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
47
+ expect(allOutput).toContain("Agent Wall Doctor");
48
+ });
49
+
50
+ it("checks Node.js version", () => {
51
+ doctorCommand({});
52
+
53
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
54
+ expect(allOutput).toContain("Node.js version");
55
+ expect(allOutput).toContain(process.versions.node);
56
+ });
57
+
58
+ it("checks policy config is valid", () => {
59
+ doctorCommand({});
60
+
61
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
62
+ expect(allOutput).toContain("Policy config");
63
+ expect(allOutput).toContain("1 rules");
64
+ });
65
+
66
+ it("reports MCP clients status", () => {
67
+ doctorCommand({});
68
+
69
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
70
+ expect(allOutput).toContain("MCP clients found");
71
+ });
72
+
73
+ it("detects MCP client when config exists", () => {
74
+ mockFs.existsSync.mockImplementation((p: fs.PathLike) => {
75
+ return String(p).includes(".cursor");
76
+ });
77
+
78
+ doctorCommand({});
79
+
80
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
81
+ expect(allOutput).toContain("Cursor");
82
+ });
83
+
84
+ it("reports environment variable overrides", () => {
85
+ const originalConfig = process.env.AGENT_WALL_CONFIG;
86
+ process.env.AGENT_WALL_CONFIG = "/custom/config.yaml";
87
+
88
+ doctorCommand({});
89
+
90
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
91
+ expect(allOutput).toContain("AGENT_WALL_CONFIG");
92
+
93
+ if (originalConfig === undefined) {
94
+ delete process.env.AGENT_WALL_CONFIG;
95
+ } else {
96
+ process.env.AGENT_WALL_CONFIG = originalConfig;
97
+ }
98
+ });
99
+
100
+ it("shows all-pass summary when everything is ok", () => {
101
+ // With valid config and Node >= 18, only MCP detection might fail
102
+ // but that's just informational
103
+ doctorCommand({});
104
+
105
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
106
+ expect(allOutput).toContain("Summary");
107
+ });
108
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * agent-wall doctor — Health check for your Agent Wall setup.
3
+ *
4
+ * Verifies: config file exists & is valid, Node.js version,
5
+ * MCP clients detected, and whether servers are wrapped.
6
+ *
7
+ * Usage:
8
+ * agent-wall doctor
9
+ * agent-wall doctor --config ./agent-wall.yaml
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+ import chalk from "chalk";
16
+ import { loadPolicy, PolicyEngine } from "@agent-wall/core";
17
+
18
+ export interface DoctorOptions {
19
+ config?: string;
20
+ }
21
+
22
+ interface CheckResult {
23
+ label: string;
24
+ ok: boolean;
25
+ detail: string;
26
+ }
27
+
28
+ export function doctorCommand(options: DoctorOptions): void {
29
+ const checks: CheckResult[] = [];
30
+
31
+ process.stderr.write("\n");
32
+ process.stderr.write(
33
+ chalk.cyan("─── Agent Wall Doctor ───────────────────────────\n\n")
34
+ );
35
+
36
+ // ── Check 1: Node.js version ────────────────────────────────────
37
+ const nodeVersion = process.versions.node;
38
+ const [major] = nodeVersion.split(".").map(Number);
39
+ checks.push({
40
+ label: "Node.js version",
41
+ ok: major >= 18,
42
+ detail: `v${nodeVersion}${major < 18 ? " (requires >= 18)" : ""}`,
43
+ });
44
+
45
+ // ── Check 2: Config file ────────────────────────────────────────
46
+ let configOk = false;
47
+ let configDetail = "";
48
+ let ruleCount = 0;
49
+ try {
50
+ const { config, filePath } = loadPolicy(options.config);
51
+ configOk = true;
52
+ ruleCount = config.rules.length;
53
+ configDetail = `${filePath ?? "built-in defaults"} (${ruleCount} rules)`;
54
+
55
+ // Also validate the engine can initialize
56
+ const engine = new PolicyEngine(config);
57
+ engine.evaluate({ name: "__doctor_test__", arguments: {} });
58
+ } catch (err) {
59
+ configDetail = err instanceof Error ? err.message : String(err);
60
+ }
61
+ checks.push({
62
+ label: "Policy config",
63
+ ok: configOk,
64
+ detail: configDetail,
65
+ });
66
+
67
+ // ── Check 3: MCP client configs detected ────────────────────────
68
+ const home = os.homedir();
69
+ const platform = os.platform();
70
+ const mcpClients: Array<{ name: string; path: string }> = [
71
+ { name: "Claude Code", path: path.join(home, ".claude", "mcp_servers.json") },
72
+ { name: "Cursor", path: path.join(home, ".cursor", "mcp.json") },
73
+ { name: "VS Code", path: path.join(process.cwd(), ".vscode", "mcp.json") },
74
+ { name: "Windsurf", path: path.join(home, ".codeium", "windsurf", "mcp_config.json") },
75
+ { name: "Cline", path: path.join(home, ".cline", "mcp_settings.json") },
76
+ ];
77
+ if (platform === "win32") {
78
+ mcpClients.push({
79
+ name: "Claude Desktop",
80
+ path: path.join(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json"),
81
+ });
82
+ } else if (platform === "darwin") {
83
+ mcpClients.push({
84
+ name: "Claude Desktop",
85
+ path: path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
86
+ });
87
+ } else {
88
+ mcpClients.push({
89
+ name: "Claude Desktop",
90
+ path: path.join(home, ".config", "Claude", "claude_desktop_config.json"),
91
+ });
92
+ }
93
+
94
+ const detected = mcpClients.filter((c) => fs.existsSync(c.path));
95
+ checks.push({
96
+ label: "MCP clients found",
97
+ ok: detected.length > 0,
98
+ detail:
99
+ detected.length > 0
100
+ ? detected.map((c) => c.name).join(", ")
101
+ : "None detected (run 'agent-wall scan' for details)",
102
+ });
103
+
104
+ // ── Check 4: Environment variables ──────────────────────────────
105
+ const envVars: string[] = [];
106
+ if (process.env.AGENT_WALL_CONFIG) envVars.push("AGENT_WALL_CONFIG");
107
+ if (process.env.AGENT_WALL_LOG) envVars.push("AGENT_WALL_LOG");
108
+ checks.push({
109
+ label: "Env overrides",
110
+ ok: true, // Always "ok" — just informational
111
+ detail: envVars.length > 0 ? envVars.join(", ") : "None set",
112
+ });
113
+
114
+ // ── Print results ───────────────────────────────────────────────
115
+ for (const check of checks) {
116
+ const icon = check.ok ? chalk.green("✓") : chalk.red("✗");
117
+ process.stderr.write(
118
+ ` ${icon} ${chalk.bold.white(check.label)}\n`
119
+ );
120
+ process.stderr.write(chalk.gray(` ${check.detail}\n\n`));
121
+ }
122
+
123
+ // ── Summary ─────────────────────────────────────────────────────
124
+ const failures = checks.filter((c) => !c.ok);
125
+ process.stderr.write(
126
+ chalk.cyan("─── Summary ────────────────────────────────────\n")
127
+ );
128
+ if (failures.length === 0) {
129
+ process.stderr.write(
130
+ chalk.green(" All checks passed. Agent Wall is ready.\n\n")
131
+ );
132
+ } else {
133
+ process.stderr.write(
134
+ chalk.yellow(
135
+ ` ${failures.length} issue(s) found:\n`
136
+ )
137
+ );
138
+ for (const f of failures) {
139
+ process.stderr.write(chalk.yellow(` • ${f.label}: ${f.detail}\n`));
140
+ }
141
+ process.stderr.write("\n");
142
+ }
143
+ process.stderr.write(
144
+ chalk.cyan("─────────────────────────────────────────────────\n\n")
145
+ );
146
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { initCommand } from "./init.js";
5
+
6
+ vi.mock("node:fs");
7
+ vi.mock("@agent-wall/core", () => ({
8
+ generateDefaultConfigYaml: () =>
9
+ "version: 1\ndefaultAction: deny\nrules: []\n",
10
+ }));
11
+
12
+ const mockFs = vi.mocked(fs);
13
+
14
+ describe("initCommand", () => {
15
+ let exitSpy: any;
16
+ let stderrSpy: any;
17
+
18
+ beforeEach(() => {
19
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
20
+ throw new Error("process.exit");
21
+ });
22
+ stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
23
+ mockFs.existsSync.mockReturnValue(false);
24
+ mockFs.writeFileSync.mockReturnValue(undefined);
25
+ mockFs.mkdirSync.mockReturnValue(undefined);
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ it("creates a new config file at default path", () => {
33
+ initCommand({});
34
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
35
+ expect.stringContaining("agent-wall.yaml"),
36
+ expect.stringContaining("version: 1"),
37
+ "utf-8"
38
+ );
39
+ });
40
+
41
+ it("creates config at custom path", () => {
42
+ initCommand({ path: "./custom/policy.yaml" });
43
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
44
+ expect.stringContaining("policy.yaml"),
45
+ expect.any(String),
46
+ "utf-8"
47
+ );
48
+ });
49
+
50
+ it("exits with error when file exists and no --force", () => {
51
+ mockFs.existsSync.mockReturnValue(true);
52
+ expect(() => initCommand({})).toThrow("process.exit");
53
+ expect(exitSpy).toHaveBeenCalledWith(1);
54
+ expect(stderrSpy).toHaveBeenCalledWith(
55
+ expect.stringContaining("already exists")
56
+ );
57
+ });
58
+
59
+ it("overwrites when --force is used", () => {
60
+ mockFs.existsSync.mockImplementation((p) => {
61
+ // File exists, but force overwrite
62
+ return String(p).endsWith("agent-wall.yaml");
63
+ });
64
+ initCommand({ force: true });
65
+ expect(mockFs.writeFileSync).toHaveBeenCalled();
66
+ });
67
+
68
+ it("creates parent directory recursively", () => {
69
+ // First existsSync check: file doesn't exist; second: dir doesn't exist
70
+ mockFs.existsSync.mockReturnValue(false);
71
+ initCommand({ path: "./deep/nested/config.yaml" });
72
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith(
73
+ expect.any(String),
74
+ { recursive: true }
75
+ );
76
+ });
77
+
78
+ it("prints success message with next steps", () => {
79
+ initCommand({});
80
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
81
+ expect(allOutput).toContain("Created");
82
+ expect(allOutput).toContain("Next steps");
83
+ expect(allOutput).toContain("agent-wall wrap");
84
+ });
85
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * agent-wall init — Generate a starter agent-wall.yaml config.
3
+ *
4
+ * Usage:
5
+ * agent-wall init
6
+ * agent-wall init --path ./config/agent-wall.yaml
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { generateDefaultConfigYaml } from "@agent-wall/core";
12
+ import chalk from "chalk";
13
+
14
+ export interface InitOptions {
15
+ path?: string;
16
+ force?: boolean;
17
+ }
18
+
19
+ export function initCommand(options: InitOptions): void {
20
+ const outputPath = path.resolve(options.path ?? "agent-wall.yaml");
21
+
22
+ if (fs.existsSync(outputPath) && !options.force) {
23
+ process.stderr.write(
24
+ chalk.yellow(
25
+ `\n⚠ File already exists: ${outputPath}\n` +
26
+ " Use --force to overwrite.\n\n"
27
+ )
28
+ );
29
+ process.exit(1);
30
+ }
31
+
32
+ const content = generateDefaultConfigYaml();
33
+ const dir = path.dirname(outputPath);
34
+ if (!fs.existsSync(dir)) {
35
+ fs.mkdirSync(dir, { recursive: true });
36
+ }
37
+ fs.writeFileSync(outputPath, content, "utf-8");
38
+
39
+ process.stderr.write(
40
+ chalk.green("\n✓ ") +
41
+ chalk.white("Created ") +
42
+ chalk.bold(outputPath) +
43
+ "\n\n" +
44
+ chalk.gray(" Next steps:\n") +
45
+ chalk.gray(" 1. Edit the rules to fit your project\n") +
46
+ chalk.gray(" 2. Wrap your MCP server:\n\n") +
47
+ chalk.white(
48
+ " agent-wall wrap -- npx @modelcontextprotocol/server-filesystem /path\n"
49
+ ) +
50
+ "\n"
51
+ );
52
+ }
@@ -0,0 +1,279 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import { scanCommand } from "./scan.js";
5
+
6
+ vi.mock("node:fs");
7
+ vi.mock("node:os");
8
+
9
+ const mockFs = vi.mocked(fs);
10
+ const mockOs = vi.mocked(os);
11
+
12
+ describe("scanCommand", () => {
13
+ let exitSpy: any;
14
+ let stderrSpy: any;
15
+ let stdoutSpy: any;
16
+
17
+ beforeEach(() => {
18
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
19
+ throw new Error("process.exit");
20
+ });
21
+ stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
22
+ stdoutSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true);
23
+ mockOs.homedir.mockReturnValue("/home/testuser");
24
+ mockOs.platform.mockReturnValue("linux" as NodeJS.Platform);
25
+ // Default: no config files found
26
+ mockFs.existsSync.mockReturnValue(false);
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ it("shows warning when no config files are found", () => {
34
+ expect(() => scanCommand({})).toThrow("process.exit");
35
+ expect(exitSpy).toHaveBeenCalledWith(0);
36
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
37
+ expect(allOutput).toContain("No MCP configuration files found");
38
+ });
39
+
40
+ it("exits with error when specified config does not exist", () => {
41
+ expect(() =>
42
+ scanCommand({ config: "/nonexistent/mcp.json" })
43
+ ).toThrow("process.exit");
44
+ expect(exitSpy).toHaveBeenCalledWith(1);
45
+ });
46
+
47
+ it("scans a config with risky filesystem server", () => {
48
+ const mcpConfig = {
49
+ mcpServers: {
50
+ myFiles: {
51
+ command: "npx",
52
+ args: ["@modelcontextprotocol/server-filesystem", "/home/user"],
53
+ },
54
+ },
55
+ };
56
+ mockFs.existsSync.mockReturnValue(true);
57
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
58
+
59
+ scanCommand({ config: "/test/mcp.json" });
60
+
61
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
62
+ expect(allOutput).toContain("myFiles");
63
+ expect(allOutput).toContain("filesystem");
64
+ expect(allOutput).toContain("Risks found");
65
+ });
66
+
67
+ it("detects multiple risk types in a single server", () => {
68
+ const mcpConfig = {
69
+ mcpServers: {
70
+ dangerousServer: {
71
+ command: "node",
72
+ args: ["server-with-shell-and-filesystem.js"],
73
+ },
74
+ },
75
+ };
76
+ mockFs.existsSync.mockReturnValue(true);
77
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
78
+
79
+ scanCommand({ config: "/test/mcp.json" });
80
+
81
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
82
+ expect(allOutput).toContain("shell");
83
+ expect(allOutput).toContain("filesystem");
84
+ });
85
+
86
+ it("shows protected status for agent-wall wrapped servers", () => {
87
+ const mcpConfig = {
88
+ mcpServers: {
89
+ secureServer: {
90
+ command: "agent-wall",
91
+ args: ["wrap", "--", "npx", "server-filesystem", "/path"],
92
+ },
93
+ },
94
+ };
95
+ mockFs.existsSync.mockReturnValue(true);
96
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
97
+
98
+ scanCommand({ config: "/test/mcp.json" });
99
+
100
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
101
+ expect(allOutput).toContain("Protected by Agent Wall");
102
+ });
103
+
104
+ it("reports clean scan for safe servers", () => {
105
+ const mcpConfig = {
106
+ mcpServers: {
107
+ safeServer: {
108
+ command: "node",
109
+ args: ["safe-read-only-server.js"],
110
+ },
111
+ },
112
+ };
113
+ mockFs.existsSync.mockReturnValue(true);
114
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
115
+
116
+ scanCommand({ config: "/test/mcp.json" });
117
+
118
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
119
+ expect(allOutput).toContain("No known risks detected");
120
+ });
121
+
122
+ it("detects critical cloud provider risks", () => {
123
+ const mcpConfig = {
124
+ mcpServers: {
125
+ cloud: {
126
+ command: "npx",
127
+ args: ["mcp-server-aws", "--region", "us-east-1"],
128
+ },
129
+ },
130
+ };
131
+ mockFs.existsSync.mockReturnValue(true);
132
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
133
+
134
+ scanCommand({ config: "/test/mcp.json" });
135
+
136
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
137
+ expect(allOutput).toContain("CRITICAL");
138
+ expect(allOutput).toContain("AWS");
139
+ });
140
+
141
+ it("handles invalid JSON config gracefully", () => {
142
+ mockFs.existsSync.mockReturnValue(true);
143
+ mockFs.readFileSync.mockReturnValue("NOT_VALID_JSON{{{");
144
+
145
+ scanCommand({ config: "/test/mcp.json" });
146
+
147
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
148
+ expect(allOutput).toContain("Failed to parse");
149
+ });
150
+
151
+ it("suggests agent-wall wrap fix for risky servers", () => {
152
+ const mcpConfig = {
153
+ mcpServers: {
154
+ db: {
155
+ command: "npx",
156
+ args: ["mcp-server-postgres", "postgresql://localhost/mydb"],
157
+ },
158
+ },
159
+ };
160
+ mockFs.existsSync.mockReturnValue(true);
161
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
162
+
163
+ scanCommand({ config: "/test/mcp.json" });
164
+
165
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
166
+ expect(allOutput).toContain("agent-wall wrap");
167
+ });
168
+
169
+ it("handles flat MCP config format (no mcpServers wrapper)", () => {
170
+ const flatConfig = {
171
+ myServer: {
172
+ command: "npx",
173
+ args: ["server-filesystem", "/tmp"],
174
+ },
175
+ };
176
+ mockFs.existsSync.mockReturnValue(true);
177
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(flatConfig));
178
+
179
+ scanCommand({ config: "/test/mcp.json" });
180
+
181
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
182
+ expect(allOutput).toContain("myServer");
183
+ });
184
+
185
+ it("detects Playwright browser automation risk", () => {
186
+ const mcpConfig = {
187
+ mcpServers: {
188
+ browser: {
189
+ command: "npx",
190
+ args: ["@anthropic/mcp-server-playwright"],
191
+ },
192
+ },
193
+ };
194
+ mockFs.existsSync.mockReturnValue(true);
195
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
196
+
197
+ scanCommand({ config: "/test/mcp.json" });
198
+
199
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
200
+ expect(allOutput).toContain("Playwright");
201
+ });
202
+
203
+ it("detects critical payment/financial risks (Stripe)", () => {
204
+ const mcpConfig = {
205
+ mcpServers: {
206
+ payments: {
207
+ command: "npx",
208
+ args: ["@stripe/mcp-server-stripe"],
209
+ },
210
+ },
211
+ };
212
+ mockFs.existsSync.mockReturnValue(true);
213
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
214
+
215
+ scanCommand({ config: "/test/mcp.json" });
216
+
217
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
218
+ expect(allOutput).toContain("CRITICAL");
219
+ expect(allOutput).toContain("Payment");
220
+ });
221
+
222
+ it("detects SSH remote access as critical", () => {
223
+ const mcpConfig = {
224
+ mcpServers: {
225
+ remote: {
226
+ command: "npx",
227
+ args: ["mcp-server-ssh", "--host", "prod.example.com"],
228
+ },
229
+ },
230
+ };
231
+ mockFs.existsSync.mockReturnValue(true);
232
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
233
+
234
+ scanCommand({ config: "/test/mcp.json" });
235
+
236
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
237
+ expect(allOutput).toContain("CRITICAL");
238
+ expect(allOutput).toContain("SSH");
239
+ });
240
+
241
+ it("detects GitHub MCP server as medium risk", () => {
242
+ const mcpConfig = {
243
+ mcpServers: {
244
+ gh: {
245
+ command: "npx",
246
+ args: ["@modelcontextprotocol/server-github"],
247
+ },
248
+ },
249
+ };
250
+ mockFs.existsSync.mockReturnValue(true);
251
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
252
+
253
+ scanCommand({ config: "/test/mcp.json" });
254
+
255
+ const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
256
+ expect(allOutput).toContain("GitHub");
257
+ });
258
+
259
+ it("outputs JSON when --json flag is set", () => {
260
+ const mcpConfig = {
261
+ mcpServers: {
262
+ myDb: {
263
+ command: "npx",
264
+ args: ["mcp-server-postgres", "postgresql://localhost/db"],
265
+ },
266
+ },
267
+ };
268
+ mockFs.existsSync.mockReturnValue(true);
269
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mcpConfig));
270
+
271
+ scanCommand({ config: "/test/mcp.json", json: true });
272
+
273
+ const jsonOut = stdoutSpy.mock.calls.map((c: any) => c[0]).join("");
274
+ const parsed = JSON.parse(jsonOut);
275
+ expect(parsed.servers).toHaveLength(1);
276
+ expect(parsed.servers[0].name).toBe("myDb");
277
+ expect(parsed.totalRisks).toBeGreaterThan(0);
278
+ });
279
+ });