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.
- package/README.md +116 -0
- package/docs/current/api-stability.ko.md +10 -4
- package/docs/current/api-stability.md +10 -4
- package/docs/current/release-0.3.2-hardening-scope.ko.md +2 -1
- package/docs/current/release-0.3.2-hardening-scope.md +2 -1
- package/docs/current/release-0.4-implementation-scope.ko.md +2 -1
- package/docs/current/release-0.4-implementation-scope.md +2 -1
- package/docs/current/release-process.ko.md +14 -4
- package/docs/current/release-process.md +14 -4
- package/docs/current/risk-register-release-gate.ko.md +10 -10
- package/docs/current/risk-register-release-gate.md +11 -11
- package/docs/current/threat-model.ko.md +3 -1
- package/docs/current/threat-model.md +3 -1
- package/haechi.config.example.json +4 -1
- package/package.json +6 -1
- package/packages/audit/index.mjs +3 -1
- package/packages/cli/bin/haechi.mjs +151 -3
- package/packages/cli/runtime.mjs +18 -1
- package/packages/core/index.mjs +18 -9
- package/packages/crypto/index.mjs +13 -1
- package/packages/filter/index.mjs +52 -3
- package/packages/mcp-stdio/index.mjs +103 -22
- package/packages/policy/index.mjs +6 -0
- package/packages/proxy/index.mjs +41 -3
- package/packages/token-vault/index.mjs +70 -2
|
@@ -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
|
`);
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -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
|
}
|
package/packages/core/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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
|
}
|