@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.
@@ -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
+ }