@quint-security/proxy 0.1.2
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/dist/http-proxy.d.ts +14 -0
- package/dist/http-proxy.d.ts.map +1 -0
- package/dist/http-proxy.js +140 -0
- package/dist/http-proxy.js.map +1 -0
- package/dist/http-relay.d.ts +45 -0
- package/dist/http-relay.d.ts.map +1 -0
- package/dist/http-relay.js +193 -0
- package/dist/http-relay.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptor.d.ts +33 -0
- package/dist/interceptor.d.ts.map +1 -0
- package/dist/interceptor.js +95 -0
- package/dist/interceptor.js.map +1 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +65 -0
- package/dist/logger.js.map +1 -0
- package/dist/relay.d.ts +34 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +72 -0
- package/dist/relay.js.map +1 -0
- package/package.json +24 -0
- package/src/http-proxy.ts +176 -0
- package/src/http-relay.ts +228 -0
- package/src/index.ts +178 -0
- package/src/interceptor.ts +127 -0
- package/src/logger.ts +85 -0
- package/src/relay.ts +90 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
|
|
4
|
+
export interface HttpRelayEvents {
|
|
5
|
+
/** Fired for every JSON-RPC request received from the agent via HTTP */
|
|
6
|
+
request: (line: string) => void;
|
|
7
|
+
/** Fired for every JSON-RPC response received from the remote server */
|
|
8
|
+
response: (line: string) => void;
|
|
9
|
+
/** Unrecoverable error */
|
|
10
|
+
error: (err: Error) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PendingRequest {
|
|
14
|
+
res: ServerResponse;
|
|
15
|
+
body: string;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Optional auth check function. Return null/undefined if auth passes,
|
|
21
|
+
* or an error message string to reject the request with 401.
|
|
22
|
+
*/
|
|
23
|
+
export type AuthCheckFn = (req: IncomingMessage) => string | undefined | null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* HttpRelay manages:
|
|
27
|
+
* - Running a local HTTP server that accepts JSON-RPC POST requests
|
|
28
|
+
* - Forwarding allowed requests to a remote MCP server via fetch()
|
|
29
|
+
* - Streaming SSE responses back when the remote uses text/event-stream
|
|
30
|
+
*
|
|
31
|
+
* The interceptor hooks into request/response events to inspect,
|
|
32
|
+
* allow, deny, or modify messages before they are forwarded.
|
|
33
|
+
*/
|
|
34
|
+
export class HttpRelay extends EventEmitter {
|
|
35
|
+
private server: Server | null = null;
|
|
36
|
+
private port: number;
|
|
37
|
+
private targetUrl: string;
|
|
38
|
+
private pending: Map<string, PendingRequest> = new Map();
|
|
39
|
+
private requestCounter = 0;
|
|
40
|
+
private authCheck: AuthCheckFn | null = null;
|
|
41
|
+
|
|
42
|
+
constructor(port: number, targetUrl: string, authCheck?: AuthCheckFn) {
|
|
43
|
+
super();
|
|
44
|
+
this.port = port;
|
|
45
|
+
this.targetUrl = targetUrl;
|
|
46
|
+
this.authCheck = authCheck ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
start(): Promise<void> {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
this.server = createServer((req, res) => {
|
|
52
|
+
this.handleRequest(req, res);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this.server.on("error", (err) => {
|
|
56
|
+
this.emit("error", err);
|
|
57
|
+
reject(err);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
this.server.listen(this.port, () => {
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
stop(): void {
|
|
67
|
+
this.server?.close();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Send a deny response back to the HTTP client for a given pending request.
|
|
72
|
+
*/
|
|
73
|
+
respondToClient(requestKey: string, body: string): void {
|
|
74
|
+
const pending = this.pending.get(requestKey);
|
|
75
|
+
if (!pending) return;
|
|
76
|
+
this.pending.delete(requestKey);
|
|
77
|
+
|
|
78
|
+
pending.res.writeHead(200, { "Content-Type": "application/json" });
|
|
79
|
+
pending.res.end(body);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Forward the original request to the remote MCP server and relay the response.
|
|
84
|
+
*/
|
|
85
|
+
async forwardToRemote(requestKey: string): Promise<void> {
|
|
86
|
+
const pending = this.pending.get(requestKey);
|
|
87
|
+
if (!pending) return;
|
|
88
|
+
this.pending.delete(requestKey);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// Forward relevant headers from the original request to the remote server
|
|
92
|
+
const forwardHeaders: Record<string, string> = {
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
Accept: "application/json, text/event-stream",
|
|
95
|
+
};
|
|
96
|
+
if (pending.headers.authorization) {
|
|
97
|
+
forwardHeaders["Authorization"] = pending.headers.authorization;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const remoteRes = await fetch(this.targetUrl, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: forwardHeaders,
|
|
103
|
+
body: pending.body,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const contentType = remoteRes.headers.get("content-type") ?? "";
|
|
107
|
+
|
|
108
|
+
if (contentType.includes("text/event-stream") && remoteRes.body) {
|
|
109
|
+
// SSE streaming — relay each event back to the client
|
|
110
|
+
pending.res.writeHead(remoteRes.status, {
|
|
111
|
+
"Content-Type": "text/event-stream",
|
|
112
|
+
"Cache-Control": "no-cache",
|
|
113
|
+
Connection: "keep-alive",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const reader = remoteRes.body.getReader();
|
|
117
|
+
const decoder = new TextDecoder();
|
|
118
|
+
let buffer = "";
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
while (true) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done) break;
|
|
124
|
+
|
|
125
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
126
|
+
buffer += chunk;
|
|
127
|
+
pending.res.write(chunk);
|
|
128
|
+
|
|
129
|
+
// Extract complete SSE data lines for logging
|
|
130
|
+
const lines = buffer.split("\n");
|
|
131
|
+
buffer = lines.pop() ?? "";
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
if (line.startsWith("data: ")) {
|
|
134
|
+
const data = line.slice(6).trim();
|
|
135
|
+
if (data) {
|
|
136
|
+
this.emit("response", data);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
// Flush remaining buffer
|
|
143
|
+
if (buffer.startsWith("data: ")) {
|
|
144
|
+
const data = buffer.slice(6).trim();
|
|
145
|
+
if (data) {
|
|
146
|
+
this.emit("response", data);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
pending.res.end();
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
// Standard JSON response
|
|
153
|
+
const responseBody = await remoteRes.text();
|
|
154
|
+
this.emit("response", responseBody);
|
|
155
|
+
|
|
156
|
+
pending.res.writeHead(remoteRes.status, {
|
|
157
|
+
"Content-Type": contentType || "application/json",
|
|
158
|
+
});
|
|
159
|
+
pending.res.end(responseBody);
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const errorBody = JSON.stringify({
|
|
163
|
+
jsonrpc: "2.0",
|
|
164
|
+
id: null,
|
|
165
|
+
error: {
|
|
166
|
+
code: -32603,
|
|
167
|
+
message: `Quint: failed to reach remote server: ${(err as Error).message}`,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
this.emit("response", errorBody);
|
|
171
|
+
pending.res.writeHead(502, { "Content-Type": "application/json" });
|
|
172
|
+
pending.res.end(errorBody);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
177
|
+
// Only handle POST requests
|
|
178
|
+
if (req.method !== "POST") {
|
|
179
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
180
|
+
res.end(JSON.stringify({ error: "Method not allowed. Use POST." }));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// CORS preflight support
|
|
185
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
186
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
187
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
188
|
+
|
|
189
|
+
// Auth check (if configured)
|
|
190
|
+
if (this.authCheck) {
|
|
191
|
+
const authError = this.authCheck(req);
|
|
192
|
+
if (authError) {
|
|
193
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
194
|
+
res.end(JSON.stringify({
|
|
195
|
+
jsonrpc: "2.0",
|
|
196
|
+
id: null,
|
|
197
|
+
error: { code: -32600, message: authError },
|
|
198
|
+
}));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const chunks: Buffer[] = [];
|
|
204
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
205
|
+
req.on("end", () => {
|
|
206
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
207
|
+
const requestKey = String(++this.requestCounter);
|
|
208
|
+
|
|
209
|
+
// Capture headers to forward to remote server
|
|
210
|
+
const headers: Record<string, string> = {};
|
|
211
|
+
for (const key of ["authorization", "content-type", "accept"]) {
|
|
212
|
+
const val = req.headers[key];
|
|
213
|
+
if (typeof val === "string") headers[key] = val;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.pending.set(requestKey, { res, body, headers });
|
|
217
|
+
|
|
218
|
+
// Emit the request for the interceptor to inspect.
|
|
219
|
+
// The interceptor will call respondToClient() for denials
|
|
220
|
+
// or forwardToRemote() for allowed requests.
|
|
221
|
+
this.emit("request", body, requestKey);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
req.on("error", (err) => {
|
|
225
|
+
this.emit("error", err);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type PolicyConfig,
|
|
3
|
+
ensureKeyPair,
|
|
4
|
+
openAuditDb,
|
|
5
|
+
resolveDataDir,
|
|
6
|
+
setLogLevel,
|
|
7
|
+
logDebug,
|
|
8
|
+
logInfo,
|
|
9
|
+
logWarn,
|
|
10
|
+
logError,
|
|
11
|
+
RiskEngine,
|
|
12
|
+
} from "@quint-security/core";
|
|
13
|
+
import { Relay } from "./relay.js";
|
|
14
|
+
import { inspectRequest, inspectResponse, buildDenyResponse } from "./interceptor.js";
|
|
15
|
+
import { AuditLogger } from "./logger.js";
|
|
16
|
+
|
|
17
|
+
export { Relay } from "./relay.js";
|
|
18
|
+
export { HttpRelay } from "./http-relay.js";
|
|
19
|
+
export { inspectRequest, inspectResponse, buildDenyResponse } from "./interceptor.js";
|
|
20
|
+
export { AuditLogger } from "./logger.js";
|
|
21
|
+
export { startHttpProxy } from "./http-proxy.js";
|
|
22
|
+
|
|
23
|
+
export interface ProxyOptions {
|
|
24
|
+
serverName: string;
|
|
25
|
+
command: string;
|
|
26
|
+
args: string[];
|
|
27
|
+
policy: PolicyConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Start the proxy: spawn child MCP server, intercept all JSON-RPC
|
|
32
|
+
* messages, enforce policy, sign and log everything.
|
|
33
|
+
*/
|
|
34
|
+
export function startProxy(opts: ProxyOptions): void {
|
|
35
|
+
setLogLevel(opts.policy.log_level);
|
|
36
|
+
const dataDir = resolveDataDir(opts.policy.data_dir);
|
|
37
|
+
|
|
38
|
+
// Ensure signing keys exist
|
|
39
|
+
const kp = ensureKeyPair(dataDir);
|
|
40
|
+
|
|
41
|
+
// Open audit database
|
|
42
|
+
const db = openAuditDb(dataDir);
|
|
43
|
+
|
|
44
|
+
// Create audit logger
|
|
45
|
+
const logger = new AuditLogger(db, kp.privateKey, kp.publicKey, opts.policy);
|
|
46
|
+
|
|
47
|
+
// Create risk engine
|
|
48
|
+
const riskEngine = new RiskEngine();
|
|
49
|
+
|
|
50
|
+
// Create relay
|
|
51
|
+
const relay = new Relay(opts.command, opts.args);
|
|
52
|
+
|
|
53
|
+
// ── Handle messages from parent (AI agent) → child (MCP server) ──
|
|
54
|
+
|
|
55
|
+
relay.on("parentMessage", (line: string) => {
|
|
56
|
+
const result = inspectRequest(line, opts.serverName, opts.policy);
|
|
57
|
+
|
|
58
|
+
// For non-tool-call requests, log immediately. Tool calls get logged after risk scoring.
|
|
59
|
+
if (!result.toolName || result.verdict === "deny") {
|
|
60
|
+
logger.log({
|
|
61
|
+
serverName: opts.serverName,
|
|
62
|
+
direction: "request",
|
|
63
|
+
method: result.method,
|
|
64
|
+
messageId: result.messageId,
|
|
65
|
+
toolName: result.toolName,
|
|
66
|
+
argumentsJson: result.argumentsJson,
|
|
67
|
+
responseJson: null,
|
|
68
|
+
verdict: result.verdict,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (result.verdict === "deny") {
|
|
73
|
+
const reqId = result.message && "id" in result.message ? result.message.id : null;
|
|
74
|
+
const errorResponse = buildDenyResponse(reqId ?? null);
|
|
75
|
+
relay.sendToParent(errorResponse);
|
|
76
|
+
logInfo(`denied ${result.toolName} on ${opts.serverName}`);
|
|
77
|
+
|
|
78
|
+
logger.log({
|
|
79
|
+
serverName: opts.serverName,
|
|
80
|
+
direction: "response",
|
|
81
|
+
method: result.method,
|
|
82
|
+
messageId: result.messageId,
|
|
83
|
+
toolName: result.toolName,
|
|
84
|
+
argumentsJson: null,
|
|
85
|
+
responseJson: errorResponse,
|
|
86
|
+
verdict: "deny",
|
|
87
|
+
});
|
|
88
|
+
} else if (result.toolName) {
|
|
89
|
+
const risk = riskEngine.score(result.toolName, result.argumentsJson, "anonymous");
|
|
90
|
+
const riskAction = riskEngine.evaluate(risk);
|
|
91
|
+
|
|
92
|
+
// Re-log the request with risk score attached
|
|
93
|
+
logger.log({
|
|
94
|
+
serverName: opts.serverName,
|
|
95
|
+
direction: "request",
|
|
96
|
+
method: result.method,
|
|
97
|
+
messageId: result.messageId,
|
|
98
|
+
toolName: result.toolName,
|
|
99
|
+
argumentsJson: result.argumentsJson,
|
|
100
|
+
responseJson: null,
|
|
101
|
+
verdict: result.verdict,
|
|
102
|
+
riskScore: risk.score,
|
|
103
|
+
riskLevel: risk.level,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (riskAction === "deny") {
|
|
107
|
+
const reqId = result.message && "id" in result.message ? result.message.id : null;
|
|
108
|
+
const errorResponse = buildDenyResponse(reqId ?? null);
|
|
109
|
+
relay.sendToParent(errorResponse);
|
|
110
|
+
logWarn(`risk-denied ${result.toolName} (score=${risk.score}, level=${risk.level}): ${risk.reasons.join("; ")}`);
|
|
111
|
+
|
|
112
|
+
logger.log({
|
|
113
|
+
serverName: opts.serverName,
|
|
114
|
+
direction: "response",
|
|
115
|
+
method: result.method,
|
|
116
|
+
messageId: result.messageId,
|
|
117
|
+
toolName: result.toolName,
|
|
118
|
+
argumentsJson: null,
|
|
119
|
+
responseJson: errorResponse,
|
|
120
|
+
verdict: "deny",
|
|
121
|
+
riskScore: risk.score,
|
|
122
|
+
riskLevel: risk.level,
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
if (riskAction === "flag") {
|
|
126
|
+
logWarn(`high-risk ${result.toolName} (score=${risk.score}, level=${risk.level}): ${risk.reasons.join("; ")}`);
|
|
127
|
+
}
|
|
128
|
+
logDebug(`forwarding ${result.method} (risk=${risk.score}) to child`);
|
|
129
|
+
relay.sendToChild(line);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (riskEngine.shouldRevoke("anonymous")) {
|
|
133
|
+
logWarn(`repeated high-risk actions detected — consider revoking agent credentials`);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
// Non-tool-call — forward directly
|
|
137
|
+
logDebug(`forwarding ${result.method} (${result.verdict}) to child`);
|
|
138
|
+
relay.sendToChild(line);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── Handle messages from child (MCP server) → parent (AI agent) ──
|
|
143
|
+
|
|
144
|
+
relay.on("childMessage", (line: string) => {
|
|
145
|
+
const result = inspectResponse(line);
|
|
146
|
+
|
|
147
|
+
// Log the response
|
|
148
|
+
logger.log({
|
|
149
|
+
serverName: opts.serverName,
|
|
150
|
+
direction: "response",
|
|
151
|
+
method: result.method,
|
|
152
|
+
messageId: result.messageId,
|
|
153
|
+
toolName: null,
|
|
154
|
+
argumentsJson: null,
|
|
155
|
+
responseJson: result.responseJson,
|
|
156
|
+
verdict: "passthrough",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Always forward responses to parent
|
|
160
|
+
relay.sendToParent(line);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── Handle child exit ──
|
|
164
|
+
|
|
165
|
+
relay.on("childExit", (code: number | null) => {
|
|
166
|
+
db.close();
|
|
167
|
+
process.exit(code ?? 0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
relay.on("error", (err: Error) => {
|
|
171
|
+
logError(`relay error: ${err.message}`);
|
|
172
|
+
db.close();
|
|
173
|
+
process.exit(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Start
|
|
177
|
+
relay.start();
|
|
178
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type JsonRpcRequest,
|
|
3
|
+
type JsonRpcMessage,
|
|
4
|
+
type Verdict,
|
|
5
|
+
type PolicyConfig,
|
|
6
|
+
isJsonRpcRequest,
|
|
7
|
+
isToolCallRequest,
|
|
8
|
+
extractToolInfo,
|
|
9
|
+
evaluatePolicy,
|
|
10
|
+
} from "@quint-security/core";
|
|
11
|
+
|
|
12
|
+
export interface InspectionResult {
|
|
13
|
+
/** The parsed JSON-RPC message (null if line is not valid JSON-RPC) */
|
|
14
|
+
message: JsonRpcMessage | null;
|
|
15
|
+
/** Policy verdict */
|
|
16
|
+
verdict: Verdict;
|
|
17
|
+
/** Extracted tool name (for tools/call requests) */
|
|
18
|
+
toolName: string | null;
|
|
19
|
+
/** Extracted tool arguments as JSON string */
|
|
20
|
+
argumentsJson: string | null;
|
|
21
|
+
/** JSON-RPC method */
|
|
22
|
+
method: string;
|
|
23
|
+
/** JSON-RPC id */
|
|
24
|
+
messageId: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Try to parse a line as JSON-RPC and determine the policy verdict.
|
|
29
|
+
* Non-parseable lines or non-tools/call methods get "passthrough".
|
|
30
|
+
*/
|
|
31
|
+
export function inspectRequest(
|
|
32
|
+
line: string,
|
|
33
|
+
serverName: string,
|
|
34
|
+
policy: PolicyConfig,
|
|
35
|
+
): InspectionResult {
|
|
36
|
+
let parsed: unknown;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(line);
|
|
39
|
+
} catch {
|
|
40
|
+
return {
|
|
41
|
+
message: null,
|
|
42
|
+
verdict: "passthrough",
|
|
43
|
+
toolName: null,
|
|
44
|
+
argumentsJson: null,
|
|
45
|
+
method: "unknown",
|
|
46
|
+
messageId: null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!isJsonRpcRequest(parsed)) {
|
|
51
|
+
return {
|
|
52
|
+
message: parsed as JsonRpcMessage,
|
|
53
|
+
verdict: "passthrough",
|
|
54
|
+
toolName: null,
|
|
55
|
+
argumentsJson: null,
|
|
56
|
+
method: "unknown",
|
|
57
|
+
messageId: extractId(parsed),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const req = parsed as JsonRpcRequest;
|
|
62
|
+
const toolInfo = extractToolInfo(req);
|
|
63
|
+
const toolName = toolInfo?.name ?? null;
|
|
64
|
+
const argumentsJson = toolInfo ? JSON.stringify(toolInfo.args) : null;
|
|
65
|
+
|
|
66
|
+
// Only policy-check tools/call; everything else is passthrough
|
|
67
|
+
let verdict: Verdict;
|
|
68
|
+
if (isToolCallRequest(req)) {
|
|
69
|
+
verdict = evaluatePolicy(policy, serverName, toolName);
|
|
70
|
+
} else {
|
|
71
|
+
verdict = "passthrough";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
message: req,
|
|
76
|
+
verdict,
|
|
77
|
+
toolName,
|
|
78
|
+
argumentsJson,
|
|
79
|
+
method: req.method,
|
|
80
|
+
messageId: req.id != null ? String(req.id) : null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Inspect a response line from the child (just for logging purposes — responses always pass through).
|
|
86
|
+
*/
|
|
87
|
+
export function inspectResponse(line: string): {
|
|
88
|
+
method: string;
|
|
89
|
+
messageId: string | null;
|
|
90
|
+
responseJson: string | null;
|
|
91
|
+
} {
|
|
92
|
+
let parsed: unknown;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(line);
|
|
95
|
+
} catch {
|
|
96
|
+
return { method: "unknown", messageId: null, responseJson: null };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
method: "response",
|
|
101
|
+
messageId: extractId(parsed),
|
|
102
|
+
responseJson: line,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build a JSON-RPC error response for a denied tool call.
|
|
108
|
+
*/
|
|
109
|
+
export function buildDenyResponse(requestId: string | number | null): string {
|
|
110
|
+
const response = {
|
|
111
|
+
jsonrpc: "2.0" as const,
|
|
112
|
+
id: requestId,
|
|
113
|
+
error: {
|
|
114
|
+
code: -32600,
|
|
115
|
+
message: "Quint: tool call denied by policy",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
return JSON.stringify(response);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function extractId(obj: unknown): string | null {
|
|
122
|
+
if (typeof obj === "object" && obj !== null && "id" in obj) {
|
|
123
|
+
const id = (obj as Record<string, unknown>).id;
|
|
124
|
+
return id != null ? String(id) : null;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AuditEntry,
|
|
3
|
+
type AuditDb,
|
|
4
|
+
type Verdict,
|
|
5
|
+
type PolicyConfig,
|
|
6
|
+
signData,
|
|
7
|
+
canonicalize,
|
|
8
|
+
sha256,
|
|
9
|
+
} from "@quint-security/core";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
export class AuditLogger {
|
|
13
|
+
private db: AuditDb;
|
|
14
|
+
private privateKey: string;
|
|
15
|
+
private publicKey: string;
|
|
16
|
+
private policyHash: string;
|
|
17
|
+
|
|
18
|
+
constructor(db: AuditDb, privateKey: string, publicKey: string, policy: PolicyConfig) {
|
|
19
|
+
this.db = db;
|
|
20
|
+
this.privateKey = privateKey;
|
|
21
|
+
this.publicKey = publicKey;
|
|
22
|
+
this.policyHash = sha256(canonicalize(policy as unknown as Record<string, unknown>));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
log(opts: {
|
|
26
|
+
serverName: string;
|
|
27
|
+
direction: "request" | "response";
|
|
28
|
+
method: string;
|
|
29
|
+
messageId: string | null;
|
|
30
|
+
toolName: string | null;
|
|
31
|
+
argumentsJson: string | null;
|
|
32
|
+
responseJson: string | null;
|
|
33
|
+
verdict: Verdict;
|
|
34
|
+
riskScore?: number | null;
|
|
35
|
+
riskLevel?: string | null;
|
|
36
|
+
}): number {
|
|
37
|
+
// Use insertAtomic to read last signature and insert in one transaction,
|
|
38
|
+
// preventing chain breaks when multiple proxy instances share the same DB.
|
|
39
|
+
return this.db.insertAtomic((prevSignature: string | null) => {
|
|
40
|
+
const timestamp = new Date().toISOString();
|
|
41
|
+
const nonce = randomUUID();
|
|
42
|
+
const prevHash = prevSignature ? sha256(prevSignature) : "";
|
|
43
|
+
|
|
44
|
+
const signable: Record<string, unknown> = {
|
|
45
|
+
timestamp,
|
|
46
|
+
server_name: opts.serverName,
|
|
47
|
+
direction: opts.direction,
|
|
48
|
+
method: opts.method,
|
|
49
|
+
message_id: opts.messageId,
|
|
50
|
+
tool_name: opts.toolName,
|
|
51
|
+
arguments_json: opts.argumentsJson,
|
|
52
|
+
response_json: opts.responseJson,
|
|
53
|
+
verdict: opts.verdict,
|
|
54
|
+
risk_score: opts.riskScore ?? null,
|
|
55
|
+
risk_level: opts.riskLevel ?? null,
|
|
56
|
+
policy_hash: this.policyHash,
|
|
57
|
+
prev_hash: prevHash,
|
|
58
|
+
nonce,
|
|
59
|
+
public_key: this.publicKey,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const canonical = canonicalize(signable);
|
|
63
|
+
const signature = signData(canonical, this.privateKey);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
timestamp,
|
|
67
|
+
server_name: opts.serverName,
|
|
68
|
+
direction: opts.direction,
|
|
69
|
+
method: opts.method,
|
|
70
|
+
message_id: opts.messageId,
|
|
71
|
+
tool_name: opts.toolName,
|
|
72
|
+
arguments_json: opts.argumentsJson,
|
|
73
|
+
response_json: opts.responseJson,
|
|
74
|
+
verdict: opts.verdict,
|
|
75
|
+
risk_score: opts.riskScore ?? null,
|
|
76
|
+
risk_level: opts.riskLevel ?? null,
|
|
77
|
+
policy_hash: this.policyHash,
|
|
78
|
+
prev_hash: prevHash,
|
|
79
|
+
nonce,
|
|
80
|
+
signature,
|
|
81
|
+
public_key: this.publicKey,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/relay.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
|
|
5
|
+
export interface RelayEvents {
|
|
6
|
+
/** Fired for every line received on stdin (from parent / AI agent) */
|
|
7
|
+
parentMessage: (line: string) => void;
|
|
8
|
+
/** Fired for every line the child process writes to stdout */
|
|
9
|
+
childMessage: (line: string) => void;
|
|
10
|
+
/** Child process exited */
|
|
11
|
+
childExit: (code: number | null, signal: string | null) => void;
|
|
12
|
+
/** Unrecoverable error */
|
|
13
|
+
error: (err: Error) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Relay manages:
|
|
18
|
+
* - Spawning the real MCP server as a child process
|
|
19
|
+
* - Reading JSON-RPC lines from stdin and forwarding to child stdin
|
|
20
|
+
* - Reading JSON-RPC lines from child stdout and forwarding to parent stdout
|
|
21
|
+
*
|
|
22
|
+
* The interceptor hooks into parentMessage/childMessage events to inspect,
|
|
23
|
+
* allow, deny, or modify messages before they are forwarded.
|
|
24
|
+
*/
|
|
25
|
+
export class Relay extends EventEmitter {
|
|
26
|
+
private child: ChildProcess | null = null;
|
|
27
|
+
private command: string;
|
|
28
|
+
private args: string[];
|
|
29
|
+
|
|
30
|
+
constructor(command: string, args: string[]) {
|
|
31
|
+
super();
|
|
32
|
+
this.command = command;
|
|
33
|
+
this.args = args;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
start(): void {
|
|
37
|
+
// Spawn the real MCP server
|
|
38
|
+
this.child = spawn(this.command, this.args, {
|
|
39
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
40
|
+
env: process.env,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Forward child stderr to our stderr (pass through diagnostics)
|
|
44
|
+
this.child.stderr?.pipe(process.stderr);
|
|
45
|
+
|
|
46
|
+
this.child.on("error", (err) => {
|
|
47
|
+
this.emit("error", err);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.child.on("exit", (code, signal) => {
|
|
51
|
+
this.emit("childExit", code, signal);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Read lines from child stdout
|
|
55
|
+
if (this.child.stdout) {
|
|
56
|
+
const childRl = createInterface({ input: this.child.stdout });
|
|
57
|
+
childRl.on("line", (line) => {
|
|
58
|
+
this.emit("childMessage", line);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Read lines from parent stdin
|
|
63
|
+
const parentRl = createInterface({ input: process.stdin });
|
|
64
|
+
parentRl.on("line", (line) => {
|
|
65
|
+
this.emit("parentMessage", line);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
parentRl.on("close", () => {
|
|
69
|
+
// Parent closed stdin — close child's stdin so it can finish and exit
|
|
70
|
+
this.child?.stdin?.end();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Send a line to the child process's stdin */
|
|
75
|
+
sendToChild(line: string): void {
|
|
76
|
+
if (this.child?.stdin?.writable) {
|
|
77
|
+
this.child.stdin.write(line + "\n");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Send a line to the parent process's stdout */
|
|
82
|
+
sendToParent(line: string): void {
|
|
83
|
+
process.stdout.write(line + "\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Gracefully shut down the child */
|
|
87
|
+
stop(): void {
|
|
88
|
+
this.child?.kill();
|
|
89
|
+
}
|
|
90
|
+
}
|