haechi 0.3.2 → 0.4.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.
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from "node:fs/promises";
3
- import { readAuditSummary } from "../../audit/index.mjs";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { readAuditSummary, verifyAuditChain } from "../../audit/index.mjs";
4
4
  import { DEFAULT_PROXY_PORT, createHaechiProxy } from "../../proxy/index.mjs";
5
5
  import { signPolicyBundleFile, verifyPolicyBundleFile } from "../../policy-bundle/index.mjs";
6
6
  import { validatePluginManifestFile } from "../../plugin/index.mjs";
7
- import { runMcpStdioFilter } from "../../mcp-stdio/index.mjs";
7
+ import { runMcpStdioFilter, wrapMcpChild } from "../../mcp-stdio/index.mjs";
8
+ import { spawn } from "node:child_process";
8
9
  import { DEFAULT_CONFIG_PATH, createRuntime, isValidPort, loadConfig, writeDefaultConfig } from "../runtime.mjs";
9
10
 
10
11
  const [command, ...argv] = process.argv.slice(2);
@@ -20,6 +21,12 @@ try {
20
21
  case "report":
21
22
  await reportCommand(argv);
22
23
  break;
24
+ case "audit-verify":
25
+ await auditVerifyCommand(argv);
26
+ break;
27
+ case "status":
28
+ await statusCommand(argv);
29
+ break;
23
30
  case "proxy":
24
31
  await proxyCommand(argv);
25
32
  break;
@@ -44,6 +51,9 @@ try {
44
51
  case "mcp-stdio":
45
52
  await mcpStdioCommand(argv);
46
53
  break;
54
+ case "mcp-wrap":
55
+ await mcpWrapCommand(argv);
56
+ break;
47
57
  case "help":
48
58
  case "--help":
49
59
  case "-h":
@@ -91,6 +101,7 @@ async function protectCommand(argv) {
91
101
  const result = await runtime.haechi.protectJson(input, {
92
102
  protocol: config.target.type,
93
103
  operation: "cli protect",
104
+ direction: "request",
94
105
  mode: effectiveMode
95
106
  });
96
107
 
@@ -123,6 +134,116 @@ async function reportCommand(argv) {
123
134
  });
124
135
  }
125
136
 
137
+ async function auditVerifyCommand(argv) {
138
+ const options = parseOptions(argv);
139
+ let auditPath = options.audit ?? options.path;
140
+ if (!auditPath) {
141
+ try {
142
+ auditPath = (await loadConfig(options.config ?? DEFAULT_CONFIG_PATH)).audit.path;
143
+ } catch {
144
+ auditPath = ".haechi/audit.jsonl";
145
+ }
146
+ }
147
+
148
+ const result = await verifyAuditChain(auditPath);
149
+ writeJson({
150
+ ok: result.valid,
151
+ command: "audit-verify",
152
+ auditPath,
153
+ result
154
+ });
155
+ if (!result.valid) {
156
+ process.exitCode = 4;
157
+ }
158
+ }
159
+
160
+ async function statusCommand(argv) {
161
+ const options = parseOptions(argv);
162
+ const configPath = options.config ?? DEFAULT_CONFIG_PATH;
163
+ const config = await loadConfig(configPath);
164
+ const effectiveMode = config.policy.mode ?? config.mode;
165
+ const enforced = !["dry-run", "report-only"].includes(effectiveMode);
166
+ const warnings = [];
167
+
168
+ if (!enforced) {
169
+ warnings.push(`policy mode is ${effectiveMode}: payloads are inspected and audited but NOT modified or blocked`);
170
+ }
171
+ if (!config.responseProtection.enabled) {
172
+ warnings.push("responseProtection.enabled is false: upstream responses are forwarded without inspection");
173
+ }
174
+ if (config.streaming.requestMode === "pass-through") {
175
+ warnings.push("streaming.requestMode is pass-through: streaming payloads are not protected");
176
+ }
177
+ if (config.tokenVault.revealPolicy !== "disabled") {
178
+ warnings.push(`tokenVault.revealPolicy is ${config.tokenVault.revealPolicy}: manual token reveal is enabled`);
179
+ }
180
+ if (config.tokenVault.detokenizeResponses && !config.responseProtection.enabled) {
181
+ warnings.push("tokenVault.detokenizeResponses is true but responseProtection.enabled is false: detokenization never runs");
182
+ }
183
+
184
+ const keys = {
185
+ provider: config.keys.provider,
186
+ keyFile: config.keys.keyFile,
187
+ exists: false,
188
+ permissions: null
189
+ };
190
+ try {
191
+ const info = await stat(config.keys.keyFile);
192
+ keys.exists = true;
193
+ keys.permissions = `0${(info.mode & 0o777).toString(8)}`;
194
+ if ((info.mode & 0o077) !== 0) {
195
+ warnings.push(`key file ${config.keys.keyFile} is group/world accessible (${keys.permissions}); expected 0600`);
196
+ }
197
+ } catch {
198
+ warnings.push(`key file ${config.keys.keyFile} does not exist; run haechi init`);
199
+ }
200
+
201
+ const audit = { path: config.audit.path, exists: false, chain: null };
202
+ try {
203
+ await stat(config.audit.path);
204
+ audit.exists = true;
205
+ audit.chain = await verifyAuditChain(config.audit.path);
206
+ if (!audit.chain.valid) {
207
+ warnings.push(`audit chain verification failed: ${audit.chain.reason}`);
208
+ }
209
+ } catch {
210
+ // No audit file yet is a normal pre-first-run state, not a warning.
211
+ }
212
+
213
+ writeJson({
214
+ ok: true,
215
+ command: "status",
216
+ configPath,
217
+ protection: {
218
+ policyMode: effectiveMode,
219
+ enforced,
220
+ responseProtection: {
221
+ enabled: config.responseProtection.enabled,
222
+ mode: config.responseProtection.mode,
223
+ failureMode: config.responseProtection.failureMode
224
+ },
225
+ streamingRequestMode: config.streaming.requestMode
226
+ },
227
+ target: {
228
+ type: config.target.type,
229
+ adapter: config.target.adapter,
230
+ upstream: config.target.upstream
231
+ },
232
+ proxy: config.proxy,
233
+ tokenVault: {
234
+ revealPolicy: config.tokenVault.revealPolicy,
235
+ retentionDays: config.tokenVault.retentionDays,
236
+ deterministic: config.tokenVault.deterministic,
237
+ deterministicTypes: config.tokenVault.deterministicTypes,
238
+ detokenizeResponses: config.tokenVault.detokenizeResponses
239
+ },
240
+ privacyProfile: config.privacy.profile,
241
+ keys,
242
+ audit,
243
+ warnings
244
+ });
245
+ }
246
+
126
247
  async function proxyCommand(argv) {
127
248
  const options = parseOptions(argv);
128
249
  const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
@@ -282,6 +403,30 @@ async function mcpStdioCommand(argv) {
282
403
  await runMcpStdioFilter({ runtime });
283
404
  }
284
405
 
406
+ async function mcpWrapCommand(argv) {
407
+ const separator = argv.indexOf("--");
408
+ if (separator === -1 || !argv[separator + 1]) {
409
+ throw new Error("mcp-wrap requires a child command after --, e.g. haechi mcp-wrap -- npx some-mcp-server");
410
+ }
411
+ const options = parseOptions(argv.slice(0, separator));
412
+ const command = argv[separator + 1];
413
+ const commandArgs = argv.slice(separator + 2);
414
+
415
+ const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
416
+ const runtime = createRuntime(config);
417
+
418
+ const child = spawn(command, commandArgs, {
419
+ stdio: ["pipe", "pipe", "inherit"]
420
+ });
421
+
422
+ for (const signal of ["SIGINT", "SIGTERM"]) {
423
+ process.on(signal, () => child.kill(signal));
424
+ }
425
+
426
+ const { code } = await wrapMcpChild({ runtime, child });
427
+ process.exitCode = code;
428
+ }
429
+
285
430
  function parseOptions(argv) {
286
431
  const options = {};
287
432
  for (let index = 0; index < argv.length; index += 1) {
@@ -326,6 +471,8 @@ Usage:
326
471
  haechi init [--config haechi.config.json] [--force]
327
472
  haechi protect <input.json> [--config haechi.config.json]
328
473
  haechi report [--audit .haechi/audit.jsonl]
474
+ haechi audit-verify [--audit .haechi/audit.jsonl] [--config haechi.config.json]
475
+ haechi status [--config haechi.config.json]
329
476
  haechi proxy [--config haechi.config.json] [--host 127.0.0.1] [--port ${DEFAULT_PROXY_PORT}] [--allow-remote-bind]
330
477
  haechi policy-sign <policy.json> [--config haechi.config.json] [--out policy.bundle.json]
331
478
  haechi policy-verify <policy.bundle.json> [--config haechi.config.json]
@@ -335,6 +482,7 @@ Usage:
335
482
  haechi token-export [--config haechi.config.json] [--type email]
336
483
  haechi plugin-validate <plugin-manifest.json>
337
484
  haechi mcp-stdio [--config haechi.config.json]
485
+ haechi mcp-wrap [--config haechi.config.json] -- <command> [args...]
338
486
 
339
487
  The default policy mode is dry-run. Change policy.mode to enforce to mutate or block payloads.
340
488
  `);
@@ -63,7 +63,10 @@ export function defaultConfig() {
63
63
  provider: "local",
64
64
  path: ".haechi/token-vault.json",
65
65
  revealPolicy: "disabled",
66
- retentionDays: 30
66
+ retentionDays: 30,
67
+ deterministic: false,
68
+ deterministicTypes: null,
69
+ detokenizeResponses: false
67
70
  },
68
71
  privacy: {
69
72
  profile: null
@@ -113,6 +116,8 @@ export function createRuntime(config, providers = {}) {
113
116
  cryptoProvider,
114
117
  revealPolicy: normalized.tokenVault.revealPolicy,
115
118
  retentionDays: normalized.tokenVault.retentionDays,
119
+ deterministic: normalized.tokenVault.deterministic,
120
+ deterministicTypes: normalized.tokenVault.deterministicTypes,
116
121
  auditSink
117
122
  });
118
123
  assertProvider("tokenVault", tokenVault, ["tokenize", "reveal", "purge"]);
@@ -233,6 +238,18 @@ export function normalizeConfig(config) {
233
238
  if (typeof merged.tokenVault.retentionDays !== "number" || merged.tokenVault.retentionDays < 1) {
234
239
  throw new Error("tokenVault.retentionDays must be a positive number");
235
240
  }
241
+ if (typeof merged.tokenVault.deterministic !== "boolean") {
242
+ throw new Error("tokenVault.deterministic must be boolean");
243
+ }
244
+ if (merged.tokenVault.deterministicTypes !== null
245
+ && (!Array.isArray(merged.tokenVault.deterministicTypes)
246
+ || merged.tokenVault.deterministicTypes.length === 0
247
+ || !merged.tokenVault.deterministicTypes.every((type) => typeof type === "string" && type.trim()))) {
248
+ throw new Error("tokenVault.deterministicTypes must be null or a non-empty array of type strings");
249
+ }
250
+ if (typeof merged.tokenVault.detokenizeResponses !== "boolean") {
251
+ throw new Error("tokenVault.detokenizeResponses must be boolean");
252
+ }
236
253
  if (!Array.isArray(merged.mcp.allowedMethods) || merged.mcp.allowedMethods.length === 0) {
237
254
  throw new Error("mcp.allowedMethods must be a non-empty array");
238
255
  }
@@ -19,11 +19,15 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
19
19
 
20
20
  const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
21
21
  const blocked = enforced && decisions.some((decision) => decision.action === "block");
22
+ // Tokens issued or reused while protecting THIS payload; the proxy uses
23
+ // this request-scoped set to restore only these tokens in the response.
24
+ const issuedTokens = new Set();
22
25
  const protectedPayload = blocked ? null : await transformPayload(payload, detections, decisions, {
23
26
  context,
24
27
  cryptoProvider,
25
28
  tokenVault,
26
- enforced
29
+ enforced,
30
+ issuedTokens
27
31
  });
28
32
 
29
33
  const auditEvent = buildAuditEvent({
@@ -42,7 +46,8 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
42
46
  payload: protectedPayload,
43
47
  blocked,
44
48
  summary: summarize(detections, decisions),
45
- auditEvent
49
+ auditEvent,
50
+ issuedTokens: [...issuedTokens]
46
51
  };
47
52
  }
48
53
 
@@ -127,7 +132,7 @@ export function summarize(detections, decisions) {
127
132
  };
128
133
  }
129
134
 
130
- async function transformPayload(payload, detections, decisions, { context, cryptoProvider, tokenVault, enforced }) {
135
+ async function transformPayload(payload, detections, decisions, { context, cryptoProvider, tokenVault, enforced, issuedTokens = null }) {
131
136
  if (!enforced || detections.length === 0) {
132
137
  return structuredClone(payload);
133
138
  }
@@ -155,7 +160,7 @@ async function transformPayload(payload, detections, decisions, { context, crypt
155
160
  if (typeof original !== "number") {
156
161
  continue;
157
162
  }
158
- const transformed = await transformString(String(original), items, { context, cryptoProvider, tokenVault });
163
+ const transformed = await transformString(String(original), items, { context, cryptoProvider, tokenVault, issuedTokens });
159
164
  if (transformed !== String(original)) {
160
165
  setByPath(output, path, transformed);
161
166
  }
@@ -164,7 +169,7 @@ async function transformPayload(payload, detections, decisions, { context, crypt
164
169
  if (typeof original !== "string") {
165
170
  continue;
166
171
  }
167
- const transformed = await transformString(original, items, { context, cryptoProvider, tokenVault });
172
+ const transformed = await transformString(original, items, { context, cryptoProvider, tokenVault, issuedTokens });
168
173
  setByPath(output, path, transformed);
169
174
  }
170
175
 
@@ -179,7 +184,7 @@ async function transformPayload(payload, detections, decisions, { context, crypt
179
184
  || !Object.prototype.hasOwnProperty.call(parent, originalKey)) {
180
185
  continue;
181
186
  }
182
- const transformedKey = await transformString(String(originalKey), items, { context, cryptoProvider, tokenVault });
187
+ const transformedKey = await transformString(String(originalKey), items, { context, cryptoProvider, tokenVault, issuedTokens });
183
188
  if (transformedKey === originalKey) {
184
189
  continue;
185
190
  }
@@ -197,7 +202,7 @@ async function transformPayload(payload, detections, decisions, { context, crypt
197
202
  return output;
198
203
  }
199
204
 
200
- async function transformString(value, items, { context, cryptoProvider, tokenVault }) {
205
+ async function transformString(value, items, { context, cryptoProvider, tokenVault, issuedTokens = null }) {
201
206
  const sorted = items
202
207
  .filter(({ decision }) => decision.action !== "allow" && decision.action !== "block")
203
208
  .sort((left, right) => left.detection.start - right.detection.start);
@@ -212,7 +217,7 @@ async function transformString(value, items, { context, cryptoProvider, tokenVau
212
217
 
213
218
  output += value.slice(cursor, detection.start);
214
219
  const segment = value.slice(detection.start, detection.end);
215
- output += await replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault });
220
+ output += await replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault, issuedTokens });
216
221
  cursor = detection.end;
217
222
  }
218
223
 
@@ -220,7 +225,7 @@ async function transformString(value, items, { context, cryptoProvider, tokenVau
220
225
  return output;
221
226
  }
222
227
 
223
- async function replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault }) {
228
+ async function replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault, issuedTokens = null }) {
224
229
  switch (decision.action) {
225
230
  case "redact":
226
231
  return `[REDACTED:${detection.type}]`;
@@ -237,6 +242,7 @@ async function replacementFor(segment, detection, decision, { context, cryptoPro
237
242
  ruleId: detection.ruleId
238
243
  }
239
244
  });
245
+ issuedTokens?.add(result.token);
240
246
  return `[TOKEN:${result.token}]`;
241
247
  }
242
248
  return `[TOKEN:${detection.type}:${shortHash(segment)}]`;
@@ -263,6 +269,9 @@ function buildAuditEvent({ context, mode, enforced, blocked, payload, detections
263
269
  timestamp: new Date().toISOString(),
264
270
  protocol: context.protocol ?? "custom",
265
271
  operation: context.operation ?? "protect",
272
+ // Reserved for 0.6 auth: hard null so unvalidated identity objects cannot
273
+ // reach the audit log before the PII-safe hashing contract exists.
274
+ identity: null,
266
275
  mode,
267
276
  enforced,
268
277
  blocked,
@@ -1,4 +1,4 @@
1
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
2
2
  import { dirname } from "node:path";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
 
@@ -60,6 +60,18 @@ export function createLocalCryptoProvider({ keyFile }) {
60
60
  aadHash: sha256(aadBytes)
61
61
  };
62
62
  },
63
+ // Keyed hash over a domain-separated derived key. The raw stored key is an
64
+ // AES-256-GCM key and must never be used for HMAC directly; every use case
65
+ // gets its own versioned domain string (e.g. deterministic tokenization,
66
+ // identity hashing). Uses the active key, so rotation changes outputs.
67
+ async hmac({ data, domain }) {
68
+ if (!domain || typeof domain !== "string") {
69
+ throw new Error("hmac requires a non-empty domain string");
70
+ }
71
+ const { active: { key } } = await loadKeys();
72
+ const derived = createHmac("sha256", key).update(domain).digest();
73
+ return createHmac("sha256", derived).update(data).digest("hex");
74
+ },
63
75
  async decrypt({ envelope, aad }) {
64
76
  const { active, byKid } = await loadKeys();
65
77
  if (envelope.alg && envelope.alg !== ALG) {
@@ -51,6 +51,50 @@ const DEFAULT_RULES = [
51
51
  pattern: "(?<=\\b(?:api[_-]?key|secret|token|password)\\s*[:=]\\s*['\\\"]?)[A-Za-z0-9._~+/-]{12,}",
52
52
  flags: "gi",
53
53
  confidence: 0.85
54
+ },
55
+ // Indirect prompt injection heuristics. Response/tool-result direction only,
56
+ // and the policy default for the injection type is `allow` (report-only):
57
+ // detections are audited regardless of action, and false-positive blocks
58
+ // would erode trust faster than missed detections.
59
+ {
60
+ id: "injection-instruction-override",
61
+ type: "injection",
62
+ pattern: "\\b(?:ignore|disregard|forget)\\s+(?:all\\s+|any\\s+|the\\s+|your\\s+)?(?:previous|prior|earlier|above|system)\\s+(?:instructions?|rules?|prompts?|guidelines)",
63
+ flags: "gi",
64
+ confidence: 0.7,
65
+ direction: "response"
66
+ },
67
+ {
68
+ id: "injection-role-reassignment",
69
+ type: "injection",
70
+ pattern: "\\b(?:you are now|act as)\\s+(?:an?\\s+)?(?:unrestricted|jailbroken|uncensored|developer mode|dan\\b)|\\bnew (?:system )?instructions?\\s*:",
71
+ flags: "gi",
72
+ confidence: 0.65,
73
+ direction: "response"
74
+ },
75
+ {
76
+ id: "injection-prompt-markers",
77
+ type: "injection",
78
+ pattern: "<\\|im_start\\|>|<<SYS>>|\\[\\[?system\\]\\]?\\s*:",
79
+ flags: "gi",
80
+ confidence: 0.7,
81
+ direction: "response"
82
+ },
83
+ {
84
+ id: "injection-conceal-from-user",
85
+ type: "injection",
86
+ pattern: "\\bdo not (?:tell|inform|mention|reveal|show)(?:\\s+this)?(?:\\s+to)?\\s+the user\\b",
87
+ flags: "gi",
88
+ confidence: 0.7,
89
+ direction: "response"
90
+ },
91
+ {
92
+ id: "injection-tool-induction",
93
+ type: "injection",
94
+ pattern: "\\b(?:silently|secretly|immediately)\\s+(?:call|invoke|run|execute)\\s+(?:the\\s+)?[\\w.-]+\\s+tool\\b",
95
+ flags: "gi",
96
+ confidence: 0.6,
97
+ direction: "response"
54
98
  }
55
99
  ];
56
100
 
@@ -64,16 +108,21 @@ export function createDefaultFilterEngine({ customRules = [] } = {}) {
64
108
  readsPlaintext: true,
65
109
  networkEgress: false
66
110
  },
67
- async detect({ entries }) {
68
- return entries.flatMap((entry) => detectEntry(entry, rules));
111
+ async detect({ entries, context }) {
112
+ return entries.flatMap((entry) => detectEntry(entry, rules, context));
69
113
  }
70
114
  };
71
115
  }
72
116
 
73
- export function detectEntry(entry, rules) {
117
+ export function detectEntry(entry, rules, context = {}) {
74
118
  const detections = [];
75
119
 
76
120
  for (const rule of rules) {
121
+ // Direction-scoped rules (e.g. injection heuristics) only run on the
122
+ // matching traffic direction; rules without a direction run everywhere.
123
+ if (rule.direction && rule.direction !== context?.direction) {
124
+ continue;
125
+ }
77
126
  const regex = new RegExp(rule.pattern, rule.flags.includes("g") ? rule.flags : `${rule.flags}g`);
78
127
  for (const match of entry.value.matchAll(regex)) {
79
128
  const value = match[0];
@@ -1,25 +1,35 @@
1
1
  import { createInterface } from "node:readline";
2
2
 
3
- export async function protectMcpJsonRpcMessage(message, runtime) {
3
+ // Tagged core used by both the one-direction line filter and mcp-wrap.
4
+ // kinds: "forward" (deliver the protected message), "reject" (send the error
5
+ // back to the CLIENT instead of delivering), "drop" (notification — deliver
6
+ // nothing, per JSON-RPC).
7
+ async function protectTagged(message, runtime, { enforceMethodAllowlist = true } = {}) {
4
8
  if (!message || typeof message !== "object" || Array.isArray(message)) {
5
9
  throw new Error(Array.isArray(message)
6
10
  ? "JSON-RPC batch messages are not supported by the MCP stdio filter"
7
11
  : "MCP message must be a JSON object");
8
12
  }
9
13
  const policy = runtime.config.mcp;
10
- // JSON-RPC notifications (method, no id) must not receive responses; a
11
- // rejected or blocked notification is dropped (returns null) instead.
12
14
  const isNotification = message.method !== undefined
13
15
  && !Object.prototype.hasOwnProperty.call(message, "id");
16
+
17
+ function reject(error) {
18
+ return isNotification ? { kind: "drop" } : { kind: "reject", message: error };
19
+ }
20
+
14
21
  if (policy.requireJsonRpc && message.jsonrpc !== "2.0") {
15
- return isNotification ? null : errorJsonRpc(message.id, -32002, "haechi_mcp_invalid_jsonrpc", {
22
+ return reject(errorJsonRpc(message.id, -32002, "haechi_mcp_invalid_jsonrpc", {
16
23
  reason: "MCP messages must use JSON-RPC 2.0"
17
- });
24
+ }));
18
25
  }
19
- if (message.method && !methodAllowed(message.method, policy.allowedMethods)) {
20
- return isNotification ? null : errorJsonRpc(message.id, -32003, "haechi_mcp_method_not_allowed", {
26
+ // The allowlist describes CLIENT-callable methods. Server-initiated requests
27
+ // (e.g. sampling/createMessage) are exempted by the caller via
28
+ // enforceMethodAllowlist: false, but their params are still protected.
29
+ if (enforceMethodAllowlist && message.method && !methodAllowed(message.method, policy.allowedMethods)) {
30
+ return reject(errorJsonRpc(message.id, -32003, "haechi_mcp_method_not_allowed", {
21
31
  method: message.method
22
- });
32
+ }));
23
33
  }
24
34
 
25
35
  const next = structuredClone(message);
@@ -28,10 +38,11 @@ export async function protectMcpJsonRpcMessage(message, runtime) {
28
38
  const result = await runtime.haechi.protectJson(next.params, {
29
39
  protocol: "mcp-stdio",
30
40
  operation: next.method ?? "params",
41
+ direction: "request",
31
42
  mode: runtime.config.policy.mode ?? runtime.config.mode
32
43
  });
33
44
  if (result.blocked) {
34
- return isNotification ? null : blockedJsonRpc(next.id, result);
45
+ return reject(blockedJsonRpc(next.id, result));
35
46
  }
36
47
  next.params = result.payload;
37
48
  }
@@ -40,15 +51,21 @@ export async function protectMcpJsonRpcMessage(message, runtime) {
40
51
  const result = await runtime.haechi.protectJson(next.result, {
41
52
  protocol: "mcp-stdio",
42
53
  operation: "result",
54
+ direction: "response",
43
55
  mode: runtime.config.policy.mode ?? runtime.config.mode
44
56
  });
45
57
  if (result.blocked) {
46
- return blockedJsonRpc(next.id, result);
58
+ return { kind: "reject", message: blockedJsonRpc(next.id, result) };
47
59
  }
48
60
  next.result = result.payload;
49
61
  }
50
62
 
51
- return next;
63
+ return { kind: "forward", message: next };
64
+ }
65
+
66
+ export async function protectMcpJsonRpcMessage(message, runtime, options = {}) {
67
+ const tagged = await protectTagged(message, runtime, options);
68
+ return tagged.kind === "drop" ? null : tagged.message;
52
69
  }
53
70
 
54
71
  export async function runMcpStdioFilter({ input = process.stdin, output = process.stdout, runtime }) {
@@ -66,21 +83,85 @@ export async function runMcpStdioFilter({ input = process.stdin, output = proces
66
83
  }
67
84
  output.write(`${JSON.stringify(protectedMessage)}\n`);
68
85
  } catch (error) {
69
- output.write(`${JSON.stringify({
70
- jsonrpc: "2.0",
71
- error: {
72
- code: -32000,
73
- message: "haechi_mcp_stdio_error",
74
- data: {
75
- reason: error.message
76
- }
77
- },
78
- id: null
79
- })}\n`);
86
+ output.write(`${JSON.stringify(stdioError(error))}\n`);
80
87
  }
81
88
  }
82
89
  }
83
90
 
91
+ // Bidirectional wrap around a spawned MCP server child process:
92
+ // client → (allowlist + params protection) → child stdin
93
+ // child stdout → (params/result protection, no client allowlist) → client
94
+ // Rejections in BOTH directions are answered to the client; nothing reaches
95
+ // the child for a rejected client message. Resolves with the child exit code.
96
+ export function wrapMcpChild({ runtime, child, input = process.stdin, output = process.stdout }) {
97
+ const clientLines = createInterface({ input, crlfDelay: Infinity });
98
+ const serverLines = createInterface({ input: child.stdout, crlfDelay: Infinity });
99
+
100
+ const clientPump = (async () => {
101
+ for await (const line of clientLines) {
102
+ if (!line.trim()) {
103
+ continue;
104
+ }
105
+ try {
106
+ const tagged = await protectTagged(JSON.parse(line), runtime, { enforceMethodAllowlist: true });
107
+ if (tagged.kind === "forward" && child.stdin.writable) {
108
+ child.stdin.write(`${JSON.stringify(tagged.message)}\n`);
109
+ } else if (tagged.kind === "reject") {
110
+ output.write(`${JSON.stringify(tagged.message)}\n`);
111
+ }
112
+ } catch (error) {
113
+ output.write(`${JSON.stringify(stdioError(error))}\n`);
114
+ }
115
+ }
116
+ if (child.stdin.writable) {
117
+ child.stdin.end();
118
+ }
119
+ })();
120
+
121
+ const serverPump = (async () => {
122
+ for await (const line of serverLines) {
123
+ if (!line.trim()) {
124
+ continue;
125
+ }
126
+ try {
127
+ const tagged = await protectTagged(JSON.parse(line), runtime, { enforceMethodAllowlist: false });
128
+ if (tagged.kind !== "drop") {
129
+ output.write(`${JSON.stringify(tagged.message)}\n`);
130
+ }
131
+ } catch (error) {
132
+ output.write(`${JSON.stringify(stdioError(error))}\n`);
133
+ }
134
+ }
135
+ })();
136
+
137
+ return new Promise((resolve, reject) => {
138
+ child.once("error", reject);
139
+ child.once("exit", (code, signal) => {
140
+ // The child is gone: stop consuming client input so the pumps can
141
+ // settle even when the caller's input stream stays open.
142
+ clientLines.close();
143
+ serverLines.close();
144
+ Promise.allSettled([clientPump, serverPump]).then(() => {
145
+ resolve({ code: code ?? (signal ? 1 : 0), signal });
146
+ });
147
+ });
148
+ });
149
+ }
150
+
151
+ function stdioError(error) {
152
+ return {
153
+ jsonrpc: "2.0",
154
+ error: {
155
+ code: -32000,
156
+ message: "haechi_mcp_stdio_error",
157
+ data: {
158
+ reason: error.message
159
+ }
160
+ },
161
+ id: null
162
+ };
163
+ }
164
+
84
165
  function blockedJsonRpc(id, result) {
85
166
  return errorJsonRpc(id, -32001, "haechi_policy_block", {
86
167
  auditId: result.auditEvent.id,
@@ -95,6 +95,12 @@ export function buildPolicy({
95
95
  allowUnsafeOverrides
96
96
  });
97
97
  }
98
+ // Injection heuristics ship report-only: unless a preset or the user sets an
99
+ // explicit action, injection detections are audited but never transform or
100
+ // block. This intentionally bypasses defaultAction.
101
+ if (!merged.actions.injection) {
102
+ merged.actions.injection = "allow";
103
+ }
98
104
  validatePolicy(merged);
99
105
  return merged;
100
106
  }