@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,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { validateCommand } from "./validate.js";
|
|
3
|
+
|
|
4
|
+
const mockConfig = {
|
|
5
|
+
version: 1,
|
|
6
|
+
defaultAction: "deny" as const,
|
|
7
|
+
rules: [
|
|
8
|
+
{ name: "block-ssh", tool: "read_file", action: "deny" as const },
|
|
9
|
+
{ name: "allow-project", tool: "list_directory", action: "allow" as const },
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
vi.mock("@agent-wall/core", () => {
|
|
14
|
+
class MockPolicyEngine {
|
|
15
|
+
constructor(_cfg: any) {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
loadPolicy: (configPath?: string) => ({
|
|
20
|
+
config: { ...mockConfig, rules: [...mockConfig.rules] },
|
|
21
|
+
filePath: configPath ?? "agent-wall.yaml",
|
|
22
|
+
}),
|
|
23
|
+
PolicyEngine: MockPolicyEngine,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("validateCommand", () => {
|
|
28
|
+
let exitSpy: any;
|
|
29
|
+
let stderrSpy: any;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
|
33
|
+
throw new Error("process.exit");
|
|
34
|
+
});
|
|
35
|
+
stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("validates a correct config successfully", () => {
|
|
43
|
+
validateCommand({});
|
|
44
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
45
|
+
expect(allOutput).toContain("Config loaded");
|
|
46
|
+
expect(allOutput).toContain("Version: 1");
|
|
47
|
+
expect(allOutput).toContain("Default action: deny");
|
|
48
|
+
expect(allOutput).toContain("Rules: 2 loaded");
|
|
49
|
+
expect(allOutput).toContain("Policy engine: OK");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("shows the config file path", () => {
|
|
53
|
+
validateCommand({});
|
|
54
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
55
|
+
expect(allOutput).toContain("agent-wall.yaml");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("accepts custom config path", () => {
|
|
59
|
+
validateCommand({ config: "./custom-policy.yaml" });
|
|
60
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
61
|
+
expect(allOutput).toContain("custom-policy.yaml");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("shows rule count", () => {
|
|
65
|
+
validateCommand({});
|
|
66
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
67
|
+
expect(allOutput).toContain("Rules: 2 loaded");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("displays validation header", () => {
|
|
71
|
+
validateCommand({});
|
|
72
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
73
|
+
expect(allOutput).toContain("Config Validation");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("validateCommand — error scenarios", () => {
|
|
78
|
+
let exitSpy: any;
|
|
79
|
+
let stderrSpy: any;
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
|
83
|
+
throw new Error("process.exit");
|
|
84
|
+
});
|
|
85
|
+
stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Note: Testing error branches that require different mock behavior
|
|
93
|
+
// would need vi.mocked module reassignment per test. These tests
|
|
94
|
+
// validate the happy path since the mock is fixed at module level.
|
|
95
|
+
// Integration tests handle error cases more naturally.
|
|
96
|
+
|
|
97
|
+
it("reports valid config with no errors or warnings", () => {
|
|
98
|
+
validateCommand({});
|
|
99
|
+
const allOutput = stderrSpy.mock.calls.map((c: any) => c[0]).join("");
|
|
100
|
+
// Should not exit with error
|
|
101
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
102
|
+
expect(allOutput).toContain("valid");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-wall validate — Validate a policy configuration file.
|
|
3
|
+
*
|
|
4
|
+
* Checks the YAML syntax, Zod schema validation, and reports
|
|
5
|
+
* any issues with your policy rules.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* agent-wall validate
|
|
9
|
+
* agent-wall validate --config ./custom-policy.yaml
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { loadPolicy, PolicyEngine } from "@agent-wall/core";
|
|
13
|
+
import chalk from "chalk";
|
|
14
|
+
|
|
15
|
+
export interface ValidateOptions {
|
|
16
|
+
config?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateCommand(options: ValidateOptions): void {
|
|
20
|
+
process.stderr.write("\n");
|
|
21
|
+
process.stderr.write(
|
|
22
|
+
chalk.cyan("─── Agent Wall Config Validation ─────────────────\n\n")
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
let config;
|
|
26
|
+
let filePath: string | null | undefined;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const result = loadPolicy(options.config);
|
|
30
|
+
config = result.config;
|
|
31
|
+
filePath = result.filePath;
|
|
32
|
+
} catch (error: any) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
chalk.red(" ✗ ") + chalk.red(`Failed to load config: ${error.message}\n\n`)
|
|
35
|
+
);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
process.stderr.write(
|
|
40
|
+
chalk.green(" ✓ ") +
|
|
41
|
+
chalk.white("Config loaded: ") +
|
|
42
|
+
chalk.gray(filePath ?? "built-in defaults") +
|
|
43
|
+
"\n"
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Validate version
|
|
47
|
+
if (config.version !== 1) {
|
|
48
|
+
process.stderr.write(
|
|
49
|
+
chalk.yellow(" ⚠ ") +
|
|
50
|
+
chalk.yellow(`Unknown config version: ${config.version} (expected 1)\n`)
|
|
51
|
+
);
|
|
52
|
+
} else {
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
chalk.green(" ✓ ") + chalk.white("Version: 1\n")
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Validate default action
|
|
59
|
+
const validActions = ["allow", "deny", "prompt"];
|
|
60
|
+
if (!config.defaultAction || !validActions.includes(config.defaultAction)) {
|
|
61
|
+
process.stderr.write(
|
|
62
|
+
chalk.red(" ✗ ") +
|
|
63
|
+
chalk.red(`Invalid defaultAction: "${config.defaultAction}" (expected: allow, deny, prompt)\n`)
|
|
64
|
+
);
|
|
65
|
+
} else {
|
|
66
|
+
process.stderr.write(
|
|
67
|
+
chalk.green(" ✓ ") +
|
|
68
|
+
chalk.white(`Default action: ${config.defaultAction}\n`)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Validate global rate limit
|
|
73
|
+
if (config.globalRateLimit) {
|
|
74
|
+
if (config.globalRateLimit.maxCalls <= 0) {
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
chalk.yellow(" ⚠ ") +
|
|
77
|
+
chalk.yellow("Global rate limit maxCalls should be > 0\n")
|
|
78
|
+
);
|
|
79
|
+
} else {
|
|
80
|
+
process.stderr.write(
|
|
81
|
+
chalk.green(" ✓ ") +
|
|
82
|
+
chalk.white(
|
|
83
|
+
`Global rate limit: ${config.globalRateLimit.maxCalls} calls / ${config.globalRateLimit.windowSeconds}s\n`
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Validate rules
|
|
90
|
+
process.stderr.write(
|
|
91
|
+
chalk.green(" ✓ ") +
|
|
92
|
+
chalk.white(`Rules: ${config.rules.length} loaded\n`)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
let warnings = 0;
|
|
96
|
+
let errors = 0;
|
|
97
|
+
const ruleNames = new Set<string>();
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < config.rules.length; i++) {
|
|
100
|
+
const rule = config.rules[i];
|
|
101
|
+
const label = rule.name ?? `rule[${i}]`;
|
|
102
|
+
|
|
103
|
+
// Check for duplicate names
|
|
104
|
+
if (rule.name) {
|
|
105
|
+
if (ruleNames.has(rule.name)) {
|
|
106
|
+
process.stderr.write(
|
|
107
|
+
chalk.yellow(" ⚠ ") +
|
|
108
|
+
chalk.yellow(`Duplicate rule name: "${rule.name}"\n`)
|
|
109
|
+
);
|
|
110
|
+
warnings++;
|
|
111
|
+
}
|
|
112
|
+
ruleNames.add(rule.name);
|
|
113
|
+
} else {
|
|
114
|
+
process.stderr.write(
|
|
115
|
+
chalk.yellow(" ⚠ ") +
|
|
116
|
+
chalk.yellow(`Rule at index ${i} has no name (recommended for audit logs)\n`)
|
|
117
|
+
);
|
|
118
|
+
warnings++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check action validity
|
|
122
|
+
if (!validActions.includes(rule.action)) {
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
chalk.red(" ✗ ") +
|
|
125
|
+
chalk.red(`${label}: invalid action "${rule.action}"\n`)
|
|
126
|
+
);
|
|
127
|
+
errors++;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check tool pattern syntax (basic validation)
|
|
131
|
+
if (rule.tool && rule.tool.includes(" ")) {
|
|
132
|
+
process.stderr.write(
|
|
133
|
+
chalk.yellow(" ⚠ ") +
|
|
134
|
+
chalk.yellow(`${label}: tool pattern contains spaces — use "|" to separate alternatives\n`)
|
|
135
|
+
);
|
|
136
|
+
warnings++;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Check rate limit
|
|
140
|
+
if (rule.rateLimit) {
|
|
141
|
+
if (rule.rateLimit.maxCalls <= 0 || rule.rateLimit.windowSeconds <= 0) {
|
|
142
|
+
process.stderr.write(
|
|
143
|
+
chalk.yellow(" ⚠ ") +
|
|
144
|
+
chalk.yellow(`${label}: rate limit values should be > 0\n`)
|
|
145
|
+
);
|
|
146
|
+
warnings++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Test that the engine initializes correctly
|
|
152
|
+
try {
|
|
153
|
+
new PolicyEngine(config);
|
|
154
|
+
process.stderr.write(
|
|
155
|
+
chalk.green(" ✓ ") + chalk.white("Policy engine: OK\n")
|
|
156
|
+
);
|
|
157
|
+
} catch (error: any) {
|
|
158
|
+
process.stderr.write(
|
|
159
|
+
chalk.red(" ✗ ") +
|
|
160
|
+
chalk.red(`Policy engine failed: ${error.message}\n`)
|
|
161
|
+
);
|
|
162
|
+
errors++;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Summary
|
|
166
|
+
process.stderr.write("\n");
|
|
167
|
+
if (errors > 0) {
|
|
168
|
+
process.stderr.write(
|
|
169
|
+
chalk.red(` ${errors} error(s), ${warnings} warning(s)\n\n`)
|
|
170
|
+
);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
} else if (warnings > 0) {
|
|
173
|
+
process.stderr.write(
|
|
174
|
+
chalk.yellow(` ${warnings} warning(s), 0 errors — config is valid\n\n`)
|
|
175
|
+
);
|
|
176
|
+
} else {
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
chalk.green(" ✓ Config is valid — no issues found\n\n")
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-wall wrap — The main command.
|
|
3
|
+
*
|
|
4
|
+
* Wraps an MCP server command, intercepting all tool calls
|
|
5
|
+
* through the Agent Wall policy engine.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* agent-wall wrap -- npx @modelcontextprotocol/server-filesystem /path
|
|
9
|
+
* agent-wall wrap -c agent-wall.yaml -- node my-mcp-server.js
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import {
|
|
16
|
+
StdioProxy,
|
|
17
|
+
PolicyEngine,
|
|
18
|
+
AuditLogger,
|
|
19
|
+
ResponseScanner,
|
|
20
|
+
InjectionDetector,
|
|
21
|
+
EgressControl,
|
|
22
|
+
KillSwitch,
|
|
23
|
+
ChainDetector,
|
|
24
|
+
DashboardServer,
|
|
25
|
+
loadPolicy,
|
|
26
|
+
createTerminalPromptHandler,
|
|
27
|
+
} from "@agent-wall/core";
|
|
28
|
+
import chalk from "chalk";
|
|
29
|
+
|
|
30
|
+
export interface WrapOptions {
|
|
31
|
+
config?: string;
|
|
32
|
+
logFile?: string;
|
|
33
|
+
silent?: boolean;
|
|
34
|
+
dryRun?: boolean;
|
|
35
|
+
dashboard?: boolean;
|
|
36
|
+
dashboardPort?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function wrapCommand(
|
|
40
|
+
serverArgs: string[],
|
|
41
|
+
options: WrapOptions
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (serverArgs.length === 0) {
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
chalk.red(
|
|
46
|
+
"Error: No server command specified.\n" +
|
|
47
|
+
"Usage: agent-wall wrap -- <command> [args...]\n\n" +
|
|
48
|
+
"Example:\n" +
|
|
49
|
+
" agent-wall wrap -- npx @modelcontextprotocol/server-filesystem /home/user\n"
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const command = serverArgs[0];
|
|
56
|
+
const args = serverArgs.slice(1);
|
|
57
|
+
|
|
58
|
+
// Load policy
|
|
59
|
+
const { config, filePath } = loadPolicy(options.config);
|
|
60
|
+
|
|
61
|
+
if (!options.silent) {
|
|
62
|
+
process.stderr.write(
|
|
63
|
+
chalk.cyan("╔══════════════════════════════════════════════════╗\n")
|
|
64
|
+
);
|
|
65
|
+
process.stderr.write(
|
|
66
|
+
chalk.cyan("║ ") +
|
|
67
|
+
chalk.bold.white("Agent Wall") +
|
|
68
|
+
chalk.cyan(" — Security Firewall for AI Agents ║\n")
|
|
69
|
+
);
|
|
70
|
+
process.stderr.write(
|
|
71
|
+
chalk.cyan("╠══════════════════════════════════════════════════╣\n")
|
|
72
|
+
);
|
|
73
|
+
process.stderr.write(
|
|
74
|
+
chalk.cyan("║ ") +
|
|
75
|
+
chalk.gray("Policy: ") +
|
|
76
|
+
chalk.white(filePath ?? "built-in defaults") +
|
|
77
|
+
"\n"
|
|
78
|
+
);
|
|
79
|
+
process.stderr.write(
|
|
80
|
+
chalk.cyan("║ ") +
|
|
81
|
+
chalk.gray("Rules: ") +
|
|
82
|
+
chalk.white(`${config.rules.length} loaded`) +
|
|
83
|
+
"\n"
|
|
84
|
+
);
|
|
85
|
+
const rspScan = config.responseScanning;
|
|
86
|
+
if (rspScan?.enabled !== false) {
|
|
87
|
+
process.stderr.write(
|
|
88
|
+
chalk.cyan("║ ") +
|
|
89
|
+
chalk.gray("Scanner:") +
|
|
90
|
+
chalk.white(" response scanning ON") +
|
|
91
|
+
(rspScan?.detectPII ? chalk.yellow(" +PII") : "") +
|
|
92
|
+
"\n"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
process.stderr.write(
|
|
96
|
+
chalk.cyan("║ ") +
|
|
97
|
+
chalk.gray("Server: ") +
|
|
98
|
+
chalk.white(`${command} ${args.join(" ")}`) +
|
|
99
|
+
"\n"
|
|
100
|
+
);
|
|
101
|
+
process.stderr.write(
|
|
102
|
+
chalk.cyan("╚══════════════════════════════════════════════════╝\n\n")
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Dry-run mode: just show info and exit
|
|
107
|
+
if (options.dryRun) {
|
|
108
|
+
process.stderr.write(
|
|
109
|
+
chalk.yellow(" --dry-run mode: preview only, server will not start\n\n")
|
|
110
|
+
);
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
chalk.gray(" Default action: ") +
|
|
113
|
+
chalk.white(config.defaultAction) +
|
|
114
|
+
"\n"
|
|
115
|
+
);
|
|
116
|
+
if (config.globalRateLimit) {
|
|
117
|
+
process.stderr.write(
|
|
118
|
+
chalk.gray(" Rate limit: ") +
|
|
119
|
+
chalk.white(
|
|
120
|
+
`${config.globalRateLimit.maxCalls} calls / ${config.globalRateLimit.windowSeconds}s`
|
|
121
|
+
) +
|
|
122
|
+
"\n"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
process.stderr.write(
|
|
126
|
+
chalk.gray(" Rules loaded: ") +
|
|
127
|
+
chalk.white(String(config.rules.length)) +
|
|
128
|
+
"\n\n"
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < config.rules.length; i++) {
|
|
132
|
+
const rule = config.rules[i];
|
|
133
|
+
const icon =
|
|
134
|
+
rule.action === "deny"
|
|
135
|
+
? chalk.red("✗")
|
|
136
|
+
: rule.action === "allow"
|
|
137
|
+
? chalk.green("✓")
|
|
138
|
+
: chalk.yellow("?");
|
|
139
|
+
process.stderr.write(
|
|
140
|
+
` ${icon} ${chalk.bold(rule.name ?? `rule[${i}]`)}` +
|
|
141
|
+
chalk.gray(` → ${rule.action}`) +
|
|
142
|
+
chalk.gray(` (tool: ${rule.tool})`) +
|
|
143
|
+
"\n"
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
process.stderr.write("\n");
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Create engine + logger + response scanner
|
|
151
|
+
const policyEngine = new PolicyEngine(config);
|
|
152
|
+
const securityConfig = config.security;
|
|
153
|
+
const logger = new AuditLogger({
|
|
154
|
+
stdout: !options.silent,
|
|
155
|
+
filePath: options.logFile,
|
|
156
|
+
redact: true,
|
|
157
|
+
signing: securityConfig?.signing ?? false,
|
|
158
|
+
signingKey: securityConfig?.signingKey,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Create response scanner from policy config
|
|
162
|
+
const responseScanner = config.responseScanning?.enabled !== false
|
|
163
|
+
? new ResponseScanner({
|
|
164
|
+
enabled: true,
|
|
165
|
+
maxResponseSize: config.responseScanning?.maxResponseSize,
|
|
166
|
+
oversizeAction: config.responseScanning?.oversizeAction,
|
|
167
|
+
detectSecrets: config.responseScanning?.detectSecrets ?? true,
|
|
168
|
+
detectPII: config.responseScanning?.detectPII ?? false,
|
|
169
|
+
base64Action: config.responseScanning?.base64Action,
|
|
170
|
+
maxPatterns: config.responseScanning?.maxPatterns,
|
|
171
|
+
patterns: config.responseScanning?.patterns,
|
|
172
|
+
})
|
|
173
|
+
: undefined;
|
|
174
|
+
|
|
175
|
+
// Create security modules from config
|
|
176
|
+
const injectionDetector = new InjectionDetector(securityConfig?.injectionDetection);
|
|
177
|
+
const egressControl = new EgressControl(securityConfig?.egressControl);
|
|
178
|
+
const killSwitch = new KillSwitch({
|
|
179
|
+
...securityConfig?.killSwitch,
|
|
180
|
+
registerSignal: true,
|
|
181
|
+
});
|
|
182
|
+
const chainDetector = new ChainDetector(securityConfig?.chainDetection);
|
|
183
|
+
|
|
184
|
+
// Show security module status
|
|
185
|
+
if (!options.silent) {
|
|
186
|
+
const modules: string[] = [];
|
|
187
|
+
if (securityConfig?.injectionDetection?.enabled !== false) modules.push("injection");
|
|
188
|
+
if (securityConfig?.egressControl?.enabled !== false) modules.push("egress");
|
|
189
|
+
if (securityConfig?.killSwitch?.enabled !== false) modules.push("kill-switch");
|
|
190
|
+
if (securityConfig?.chainDetection?.enabled !== false) modules.push("chain");
|
|
191
|
+
if (securityConfig?.signing) modules.push("signing");
|
|
192
|
+
if (modules.length > 0) {
|
|
193
|
+
process.stderr.write(
|
|
194
|
+
chalk.cyan("║ ") +
|
|
195
|
+
chalk.gray("Security:") +
|
|
196
|
+
chalk.white(` ${modules.join(", ")}`) +
|
|
197
|
+
"\n"
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Create proxy
|
|
203
|
+
const proxy = new StdioProxy({
|
|
204
|
+
command,
|
|
205
|
+
args,
|
|
206
|
+
policyEngine,
|
|
207
|
+
responseScanner,
|
|
208
|
+
logger,
|
|
209
|
+
injectionDetector,
|
|
210
|
+
egressControl,
|
|
211
|
+
killSwitch,
|
|
212
|
+
chainDetector,
|
|
213
|
+
onPrompt: createTerminalPromptHandler(),
|
|
214
|
+
onReady: () => {
|
|
215
|
+
if (!options.silent) {
|
|
216
|
+
process.stderr.write(
|
|
217
|
+
chalk.green("✓ ") +
|
|
218
|
+
chalk.gray("MCP server started. Agent Wall is protecting.\n\n")
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
onExit: (code) => {
|
|
223
|
+
if (!options.silent) {
|
|
224
|
+
const stats = proxy.getStats();
|
|
225
|
+
process.stderr.write("\n");
|
|
226
|
+
process.stderr.write(
|
|
227
|
+
chalk.cyan("─── Agent Wall Session Summary ────────────────────\n")
|
|
228
|
+
);
|
|
229
|
+
process.stderr.write(
|
|
230
|
+
chalk.gray(" Total calls: ") +
|
|
231
|
+
chalk.white(String(stats.total)) +
|
|
232
|
+
"\n"
|
|
233
|
+
);
|
|
234
|
+
process.stderr.write(
|
|
235
|
+
chalk.gray(" Forwarded: ") +
|
|
236
|
+
chalk.green(String(stats.forwarded)) +
|
|
237
|
+
"\n"
|
|
238
|
+
);
|
|
239
|
+
process.stderr.write(
|
|
240
|
+
chalk.gray(" Denied: ") +
|
|
241
|
+
chalk.red(String(stats.denied)) +
|
|
242
|
+
"\n"
|
|
243
|
+
);
|
|
244
|
+
process.stderr.write(
|
|
245
|
+
chalk.gray(" Prompted: ") +
|
|
246
|
+
chalk.yellow(String(stats.prompted)) +
|
|
247
|
+
"\n"
|
|
248
|
+
);
|
|
249
|
+
if (stats.scanned > 0) {
|
|
250
|
+
process.stderr.write(
|
|
251
|
+
chalk.gray(" Responses scanned: ") +
|
|
252
|
+
chalk.white(String(stats.scanned)) +
|
|
253
|
+
"\n"
|
|
254
|
+
);
|
|
255
|
+
if (stats.responseBlocked > 0) {
|
|
256
|
+
process.stderr.write(
|
|
257
|
+
chalk.gray(" Resp. blocked: ") +
|
|
258
|
+
chalk.red(String(stats.responseBlocked)) +
|
|
259
|
+
"\n"
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (stats.responseRedacted > 0) {
|
|
263
|
+
process.stderr.write(
|
|
264
|
+
chalk.gray(" Resp. redacted: ") +
|
|
265
|
+
chalk.yellow(String(stats.responseRedacted)) +
|
|
266
|
+
"\n"
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
process.stderr.write(
|
|
271
|
+
chalk.cyan("─────────────────────────────────────────────────\n")
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
process.exit(code ?? 0);
|
|
275
|
+
},
|
|
276
|
+
onError: (error) => {
|
|
277
|
+
process.stderr.write(
|
|
278
|
+
chalk.red(`\nAgent Wall Error: ${error.message}\n`)
|
|
279
|
+
);
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── Policy hot-reload ────────────────────────────────────────────
|
|
284
|
+
// Watch the policy file for changes and reload without restarting.
|
|
285
|
+
let policyWatcher: fs.FSWatcher | null = null;
|
|
286
|
+
if (filePath) {
|
|
287
|
+
try {
|
|
288
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
289
|
+
policyWatcher = fs.watch(filePath, (eventType) => {
|
|
290
|
+
if (eventType !== "change") return;
|
|
291
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
292
|
+
debounceTimer = setTimeout(() => {
|
|
293
|
+
try {
|
|
294
|
+
const { config: newConfig } = loadPolicy(filePath);
|
|
295
|
+
policyEngine.updateConfig(newConfig);
|
|
296
|
+
if (responseScanner && newConfig.responseScanning) {
|
|
297
|
+
responseScanner.updateConfig(newConfig.responseScanning);
|
|
298
|
+
}
|
|
299
|
+
if (!options.silent) {
|
|
300
|
+
process.stderr.write(
|
|
301
|
+
chalk.green("✓ ") +
|
|
302
|
+
chalk.gray("Policy reloaded from ") +
|
|
303
|
+
chalk.white(filePath) +
|
|
304
|
+
chalk.gray(` (${newConfig.rules.length} rules)\n`)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
} catch (err: unknown) {
|
|
308
|
+
if (!options.silent) {
|
|
309
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
310
|
+
process.stderr.write(
|
|
311
|
+
chalk.yellow(`⚠ Policy reload failed: ${msg}\n`)
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}, 300); // Debounce: 300ms to handle rapid file saves
|
|
316
|
+
});
|
|
317
|
+
} catch {
|
|
318
|
+
// File watching not available — continue without hot-reload
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Dashboard ─────────────────────────────────────────────────────
|
|
323
|
+
let dashboardServer: DashboardServer | null = null;
|
|
324
|
+
if (options.dashboard || options.dashboardPort) {
|
|
325
|
+
const dashboardPort = options.dashboardPort ?? 61100;
|
|
326
|
+
const staticDir = resolveDashboardAssets();
|
|
327
|
+
|
|
328
|
+
dashboardServer = new DashboardServer({
|
|
329
|
+
port: dashboardPort,
|
|
330
|
+
proxy,
|
|
331
|
+
killSwitch,
|
|
332
|
+
policyEngine,
|
|
333
|
+
logger,
|
|
334
|
+
staticDir,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Wire audit logger to push entries to dashboard
|
|
338
|
+
logger.setOnEntry((entry) => {
|
|
339
|
+
dashboardServer!.handleAuditEntry(entry);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
await dashboardServer.start();
|
|
344
|
+
if (!options.silent) {
|
|
345
|
+
process.stderr.write(
|
|
346
|
+
chalk.cyan("║ ") +
|
|
347
|
+
chalk.gray("Dashboard:") +
|
|
348
|
+
chalk.white(` http://localhost:${dashboardPort}`) +
|
|
349
|
+
"\n"
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
} catch (err: unknown) {
|
|
353
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
354
|
+
process.stderr.write(
|
|
355
|
+
chalk.yellow(`⚠ Dashboard failed to start: ${msg}\n`)
|
|
356
|
+
);
|
|
357
|
+
dashboardServer = null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Handle process signals
|
|
362
|
+
const shutdown = () => {
|
|
363
|
+
if (policyWatcher) {
|
|
364
|
+
policyWatcher.close();
|
|
365
|
+
policyWatcher = null;
|
|
366
|
+
}
|
|
367
|
+
dashboardServer?.stop();
|
|
368
|
+
proxy.stop();
|
|
369
|
+
};
|
|
370
|
+
process.on("SIGINT", shutdown);
|
|
371
|
+
process.on("SIGTERM", shutdown);
|
|
372
|
+
|
|
373
|
+
// Start the proxy
|
|
374
|
+
try {
|
|
375
|
+
await proxy.start();
|
|
376
|
+
} catch (error: unknown) {
|
|
377
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
378
|
+
process.stderr.write(
|
|
379
|
+
chalk.red(
|
|
380
|
+
`\nFailed to start MCP server: ${msg}\n\n` +
|
|
381
|
+
"Make sure the server command is correct:\n" +
|
|
382
|
+
` ${command} ${args.join(" ")}\n`
|
|
383
|
+
)
|
|
384
|
+
);
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Dashboard Asset Resolution ──────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function resolveDashboardAssets(): string | undefined {
|
|
392
|
+
// 1. Check for bundled assets (shipped with npm package)
|
|
393
|
+
const bundledDir = path.join(path.dirname(new URL(import.meta.url).pathname), "dashboard");
|
|
394
|
+
if (fs.existsSync(path.join(bundledDir, "index.html"))) {
|
|
395
|
+
return bundledDir;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 2. Try to find @agent-wall/dashboard's built assets via require.resolve
|
|
399
|
+
try {
|
|
400
|
+
const require = createRequire(import.meta.url);
|
|
401
|
+
const pkgPath = require.resolve("@agent-wall/dashboard/package.json");
|
|
402
|
+
const distDir = path.join(path.dirname(pkgPath), "dist");
|
|
403
|
+
if (fs.existsSync(path.join(distDir, "index.html"))) {
|
|
404
|
+
return distDir;
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
// Not installed or not built
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 3. Fallback: check monorepo path
|
|
411
|
+
const monorepoDist = path.resolve(
|
|
412
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
413
|
+
"../../dashboard/dist"
|
|
414
|
+
);
|
|
415
|
+
if (fs.existsSync(path.join(monorepoDist, "index.html"))) {
|
|
416
|
+
return monorepoDist;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|