haechi 0.3.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.
Files changed (54) hide show
  1. package/LICENSE +154 -0
  2. package/README.md +102 -0
  3. package/SECURITY.md +31 -0
  4. package/docs/README.md +35 -0
  5. package/docs/current/api-stability.ko.md +48 -0
  6. package/docs/current/api-stability.md +48 -0
  7. package/docs/current/expert-gap-review-ai-llm-mcp-encryption.ko.md +107 -0
  8. package/docs/current/expert-gap-review-ai-llm-mcp-encryption.md +107 -0
  9. package/docs/current/global-privacy-compliance-review.ko.md +110 -0
  10. package/docs/current/global-privacy-compliance-review.md +110 -0
  11. package/docs/current/initial-plan-ai-llm-mcp-encryption.ko.md +214 -0
  12. package/docs/current/initial-plan-ai-llm-mcp-encryption.md +214 -0
  13. package/docs/current/mvp-0.1-implementation-scope.ko.md +79 -0
  14. package/docs/current/mvp-0.1-implementation-scope.md +79 -0
  15. package/docs/current/open-source-modular-architecture.ko.md +387 -0
  16. package/docs/current/open-source-modular-architecture.md +387 -0
  17. package/docs/current/prd-ai-llm-mcp-encryption.ko.md +260 -0
  18. package/docs/current/prd-ai-llm-mcp-encryption.md +262 -0
  19. package/docs/current/privacy-filtering-policy-draft.ko.md +307 -0
  20. package/docs/current/privacy-filtering-policy-draft.md +307 -0
  21. package/docs/current/release-0.2-implementation-scope.ko.md +46 -0
  22. package/docs/current/release-0.2-implementation-scope.md +46 -0
  23. package/docs/current/release-0.3-implementation-scope.ko.md +86 -0
  24. package/docs/current/release-0.3-implementation-scope.md +86 -0
  25. package/docs/current/release-0.3.2-hardening-scope.ko.md +64 -0
  26. package/docs/current/release-0.3.2-hardening-scope.md +64 -0
  27. package/docs/current/release-0.4-implementation-scope.ko.md +121 -0
  28. package/docs/current/release-0.4-implementation-scope.md +121 -0
  29. package/docs/current/release-process.ko.md +48 -0
  30. package/docs/current/release-process.md +48 -0
  31. package/docs/current/risk-register-release-gate.ko.md +154 -0
  32. package/docs/current/risk-register-release-gate.md +154 -0
  33. package/docs/current/shared-responsibility.ko.md +38 -0
  34. package/docs/current/shared-responsibility.md +38 -0
  35. package/docs/current/threat-model.ko.md +68 -0
  36. package/docs/current/threat-model.md +68 -0
  37. package/examples/llm-prompt-filtering/input.json +13 -0
  38. package/examples/plugins/custom-filter.plugin.json +29 -0
  39. package/haechi.config.example.json +70 -0
  40. package/package.json +74 -0
  41. package/packages/audit/index.mjs +262 -0
  42. package/packages/cli/bin/haechi.mjs +341 -0
  43. package/packages/cli/runtime.mjs +287 -0
  44. package/packages/core/index.mjs +309 -0
  45. package/packages/crypto/index.mjs +142 -0
  46. package/packages/filter/index.mjs +189 -0
  47. package/packages/mcp-stdio/index.mjs +105 -0
  48. package/packages/plugin/index.mjs +83 -0
  49. package/packages/policy/index.mjs +165 -0
  50. package/packages/policy-bundle/index.mjs +91 -0
  51. package/packages/privacy-profiles/index.mjs +92 -0
  52. package/packages/protocol-adapters/index.mjs +111 -0
  53. package/packages/proxy/index.mjs +534 -0
  54. package/packages/token-vault/index.mjs +262 -0
@@ -0,0 +1,534 @@
1
+ import { createServer } from "node:http";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+
4
+ export const DEFAULT_PROXY_PORT = 1016;
5
+
6
+ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "127.0.0.1", allowRemoteBind = false }) {
7
+ assertSafeProxyBind({ host, allowRemoteBind });
8
+ const { haechi, config, protocolAdapter } = runtime;
9
+
10
+ const server = createServer(async (request, response) => {
11
+ try {
12
+ if (request.method === "GET" && request.url === "/__haechi/health") {
13
+ writeJson(response, 200, { ok: true, mode: config.mode });
14
+ return;
15
+ }
16
+
17
+ assertRelativeProxyTarget(request.url);
18
+ const routeContext = protocolAdapter.classifyRequest(request);
19
+ const body = await readBody(request, {
20
+ maxBytes: config.limits.maxRequestBytes
21
+ });
22
+ const json = parseJsonBody(body);
23
+
24
+ if (isStreamingRequest(json, routeContext)) {
25
+ if (config.streaming.requestMode === "pass-through") {
26
+ await recordProxyDecision({
27
+ runtime,
28
+ routeContext,
29
+ decision: "streaming_request_pass_through",
30
+ reason: "streaming_request_pass_through",
31
+ enforced: false,
32
+ blocked: false
33
+ });
34
+ const upstreamResponse = await forward({
35
+ upstream: config.target.upstream,
36
+ request,
37
+ body,
38
+ timeoutMs: config.limits.upstreamTimeoutMs
39
+ });
40
+ const { body: rawBody } = await readUpstreamBody(upstreamResponse);
41
+ response.writeHead(upstreamResponse.status, Object.fromEntries(upstreamResponse.headers.entries()));
42
+ response.end(rawBody);
43
+ return;
44
+ }
45
+
46
+ writeJson(response, 501, {
47
+ error: "haechi_streaming_unsupported",
48
+ message: "Streaming requests are blocked unless streaming.requestMode is explicitly set to pass-through"
49
+ });
50
+ return;
51
+ }
52
+
53
+ const result = routeContext.protectRequest
54
+ ? await haechi.protectJson(json, {
55
+ ...routeContext,
56
+ operation: `request:${routeContext.operation}`,
57
+ mode: config.policy.mode ?? config.mode
58
+ })
59
+ : { payload: json, blocked: false };
60
+
61
+ if (result.blocked) {
62
+ writeJson(response, 403, {
63
+ error: "haechi_policy_block",
64
+ summary: result.summary,
65
+ auditId: result.auditEvent.id
66
+ });
67
+ return;
68
+ }
69
+
70
+ const upstreamResponse = await forward({
71
+ upstream: config.target.upstream,
72
+ request,
73
+ body: JSON.stringify(result.payload),
74
+ timeoutMs: config.limits.upstreamTimeoutMs
75
+ });
76
+
77
+ const forwarded = await maybeProtectResponse({
78
+ upstreamResponse,
79
+ routeContext,
80
+ runtime
81
+ });
82
+
83
+ response.writeHead(forwarded.status, forwarded.headers);
84
+ response.end(forwarded.body);
85
+ } catch (error) {
86
+ const expected = typeof error?.statusCode === "number";
87
+ if (!expected) {
88
+ console.error(`haechi proxy internal error: ${error?.stack ?? error?.message ?? error}`);
89
+ }
90
+ writeJson(response, error.statusCode ?? 500, {
91
+ error: error.errorCode ?? "haechi_proxy_error",
92
+ message: expected ? error.message : "Internal proxy error"
93
+ });
94
+ }
95
+ });
96
+
97
+ return {
98
+ server,
99
+ listen() {
100
+ return new Promise((resolve) => {
101
+ server.listen(port, host, () => {
102
+ const address = server.address();
103
+ resolve({ host: address.address, port: address.port });
104
+ });
105
+ });
106
+ },
107
+ close() {
108
+ return new Promise((resolve, reject) => {
109
+ server.close((error) => error ? reject(error) : resolve());
110
+ });
111
+ }
112
+ };
113
+ }
114
+
115
+ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime }) {
116
+ const headers = Object.fromEntries(upstreamResponse.headers.entries());
117
+
118
+ if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
119
+ const { body: rawBody } = await readUpstreamBody(upstreamResponse);
120
+ return {
121
+ status: upstreamResponse.status,
122
+ headers,
123
+ body: rawBody
124
+ };
125
+ }
126
+
127
+ const responsePolicy = runtime.config.responseProtection;
128
+ const contentEncoding = headers["content-encoding"] ?? "";
129
+ const bodyRead = await readUpstreamBody(upstreamResponse, { maxBytes: responsePolicy.maxBytes });
130
+
131
+ if (bodyRead.tooLarge) {
132
+ return unprotectedResponseDecision({
133
+ reason: "response_body_too_large",
134
+ detail: `Response body exceeds responseProtection.maxBytes (${responsePolicy.maxBytes})`,
135
+ upstreamResponse,
136
+ headers,
137
+ rawBody: bodyRead.body,
138
+ responsePolicy,
139
+ routeContext,
140
+ runtime,
141
+ hardDeny: true
142
+ });
143
+ }
144
+
145
+ const rawBody = bodyRead.body;
146
+
147
+ if (rawBody.byteLength > responsePolicy.maxBytes) {
148
+ return unprotectedResponseDecision({
149
+ reason: "response_body_too_large",
150
+ detail: `Response body exceeds responseProtection.maxBytes (${responsePolicy.maxBytes})`,
151
+ upstreamResponse,
152
+ headers,
153
+ rawBody,
154
+ responsePolicy,
155
+ routeContext,
156
+ runtime,
157
+ hardDeny: true
158
+ });
159
+ }
160
+
161
+ if (contentEncoding && contentEncoding.toLowerCase() !== "identity" && !responsePolicy.allowCompressed) {
162
+ return unprotectedResponseDecision({
163
+ reason: "compressed_response",
164
+ detail: "Compressed responses cannot be inspected by responseProtection",
165
+ upstreamResponse,
166
+ headers,
167
+ rawBody,
168
+ responsePolicy,
169
+ routeContext,
170
+ runtime
171
+ });
172
+ }
173
+
174
+ if (!isJson(headers["content-type"])) {
175
+ return unprotectedResponseDecision({
176
+ reason: "non_json_response",
177
+ detail: "Non-JSON responses cannot be inspected by responseProtection",
178
+ upstreamResponse,
179
+ headers,
180
+ rawBody,
181
+ responsePolicy,
182
+ routeContext,
183
+ runtime
184
+ });
185
+ }
186
+
187
+ let json;
188
+ try {
189
+ json = JSON.parse(rawBody.toString("utf8"));
190
+ } catch (error) {
191
+ return unprotectedResponseDecision({
192
+ reason: "invalid_json_response",
193
+ detail: error.message,
194
+ upstreamResponse,
195
+ headers,
196
+ rawBody,
197
+ responsePolicy,
198
+ routeContext,
199
+ runtime
200
+ });
201
+ }
202
+
203
+ const result = await runtime.haechi.protectJson(json, {
204
+ ...routeContext,
205
+ operation: `response:${routeContext.operation}`,
206
+ mode: runtime.config.responseProtection.mode ?? runtime.config.policy.mode ?? runtime.config.mode
207
+ });
208
+
209
+ if (result.blocked) {
210
+ return {
211
+ status: 502,
212
+ headers: { "content-type": "application/json" },
213
+ body: Buffer.from(`${JSON.stringify({
214
+ error: "haechi_response_policy_block",
215
+ summary: result.summary,
216
+ auditId: result.auditEvent.id
217
+ }, null, 2)}\n`)
218
+ };
219
+ }
220
+
221
+ return {
222
+ status: upstreamResponse.status,
223
+ headers: transformedJsonHeaders(headers),
224
+ body: Buffer.from(`${JSON.stringify(result.payload)}\n`)
225
+ };
226
+ }
227
+
228
+ async function forward({ upstream, request, body, timeoutMs = null }) {
229
+ const target = buildUpstreamUrl({ upstream, requestUrl: request.url });
230
+ try {
231
+ return await fetch(target, {
232
+ method: request.method,
233
+ headers: filteredHeaders(request.headers),
234
+ body: request.method === "GET" || request.method === "HEAD" ? undefined : body,
235
+ signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined
236
+ });
237
+ } catch (error) {
238
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
239
+ throw proxyError({
240
+ statusCode: 504,
241
+ errorCode: "haechi_upstream_timeout",
242
+ message: `Upstream did not respond within limits.upstreamTimeoutMs (${timeoutMs})`
243
+ });
244
+ }
245
+ throw proxyError({
246
+ statusCode: 502,
247
+ errorCode: "haechi_upstream_unreachable",
248
+ message: "Upstream request failed"
249
+ });
250
+ }
251
+ }
252
+
253
+ function buildUpstreamUrl({ upstream, requestUrl }) {
254
+ assertRelativeProxyTarget(requestUrl);
255
+ const parsed = new URL(requestUrl, "http://haechi.local");
256
+ return new URL(`${parsed.pathname}${parsed.search}`, upstream.endsWith("/") ? upstream : `${upstream}/`);
257
+ }
258
+
259
+ function filteredHeaders(headers) {
260
+ const next = new Headers();
261
+ for (const [key, value] of Object.entries(headers)) {
262
+ if (!value || ["host", "content-length"].includes(key.toLowerCase())) {
263
+ continue;
264
+ }
265
+ if (Array.isArray(value)) {
266
+ for (const item of value) {
267
+ next.append(key, item);
268
+ }
269
+ } else {
270
+ next.set(key, value);
271
+ }
272
+ }
273
+ next.set("content-type", "application/json");
274
+ return next;
275
+ }
276
+
277
+ function readBody(request, { maxBytes }) {
278
+ return new Promise((resolve, reject) => {
279
+ const chunks = [];
280
+ let received = 0;
281
+ let rejected = false;
282
+
283
+ request.on("data", (chunk) => {
284
+ if (rejected) {
285
+ return;
286
+ }
287
+ received += chunk.byteLength;
288
+ if (received > maxBytes) {
289
+ rejected = true;
290
+ reject(proxyError({
291
+ statusCode: 413,
292
+ errorCode: "haechi_request_body_too_large",
293
+ message: `Request body exceeds limits.maxRequestBytes (${maxBytes})`
294
+ }));
295
+ return;
296
+ }
297
+ chunks.push(chunk);
298
+ });
299
+ request.on("end", () => {
300
+ if (!rejected) {
301
+ resolve(Buffer.concat(chunks).toString("utf8"));
302
+ }
303
+ });
304
+ request.on("error", (error) => {
305
+ if (!rejected) {
306
+ reject(error);
307
+ }
308
+ });
309
+ });
310
+ }
311
+
312
+ function parseJsonBody(body) {
313
+ if (!body) {
314
+ return {};
315
+ }
316
+
317
+ try {
318
+ return JSON.parse(body);
319
+ } catch (error) {
320
+ throw proxyError({
321
+ statusCode: 400,
322
+ errorCode: "haechi_invalid_json_request",
323
+ message: error.message
324
+ });
325
+ }
326
+ }
327
+
328
+ function writeJson(response, status, body) {
329
+ response.writeHead(status, { "content-type": "application/json" });
330
+ response.end(`${JSON.stringify(body, null, 2)}\n`);
331
+ }
332
+
333
+ function isJson(contentType = "") {
334
+ return contentType.toLowerCase().includes("application/json");
335
+ }
336
+
337
+ function transformedJsonHeaders(headers) {
338
+ const next = { ...headers, "content-type": "application/json" };
339
+ delete next["content-length"];
340
+ delete next["content-encoding"];
341
+ return next;
342
+ }
343
+
344
+ async function unprotectedResponseDecision({
345
+ reason,
346
+ detail,
347
+ upstreamResponse,
348
+ headers,
349
+ rawBody,
350
+ responsePolicy,
351
+ routeContext,
352
+ runtime,
353
+ hardDeny = false
354
+ }) {
355
+ const allowed = responsePolicy.failureMode === "allow" && !hardDeny;
356
+ await recordProxyDecision({
357
+ runtime,
358
+ routeContext,
359
+ decision: allowed ? "response_unprotected_allowed" : "response_unprotected_blocked",
360
+ reason,
361
+ enforced: !allowed,
362
+ blocked: !allowed
363
+ });
364
+
365
+ if (allowed) {
366
+ return {
367
+ status: upstreamResponse.status,
368
+ headers,
369
+ body: rawBody
370
+ };
371
+ }
372
+
373
+ return {
374
+ status: 502,
375
+ headers: { "content-type": "application/json" },
376
+ body: Buffer.from(`${JSON.stringify({
377
+ error: "haechi_response_unprotected",
378
+ reason,
379
+ message: detail
380
+ }, null, 2)}\n`)
381
+ };
382
+ }
383
+
384
+ async function readUpstreamBody(upstreamResponse, { maxBytes = null } = {}) {
385
+ const contentLength = parseContentLength(upstreamResponse.headers.get("content-length"));
386
+ if (maxBytes && contentLength !== null && contentLength > maxBytes) {
387
+ void cancelUpstreamBody(upstreamResponse);
388
+ return {
389
+ body: Buffer.alloc(0),
390
+ tooLarge: true,
391
+ receivedBytes: contentLength
392
+ };
393
+ }
394
+
395
+ if (!upstreamResponse.body) {
396
+ return {
397
+ body: Buffer.alloc(0),
398
+ tooLarge: false,
399
+ receivedBytes: 0
400
+ };
401
+ }
402
+
403
+ const reader = upstreamResponse.body.getReader();
404
+ const chunks = [];
405
+ let receivedBytes = 0;
406
+
407
+ while (true) {
408
+ const { done, value } = await reader.read();
409
+ if (done) {
410
+ return {
411
+ body: Buffer.concat(chunks),
412
+ tooLarge: false,
413
+ receivedBytes
414
+ };
415
+ }
416
+
417
+ receivedBytes += value.byteLength;
418
+ if (maxBytes && receivedBytes > maxBytes) {
419
+ void cancelReader(reader);
420
+ return {
421
+ body: Buffer.concat(chunks),
422
+ tooLarge: true,
423
+ receivedBytes
424
+ };
425
+ }
426
+ chunks.push(Buffer.from(value));
427
+ }
428
+ }
429
+
430
+ function parseContentLength(value) {
431
+ if (!value) {
432
+ return null;
433
+ }
434
+ const parsed = Number.parseInt(value, 10);
435
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
436
+ }
437
+
438
+ async function cancelUpstreamBody(upstreamResponse) {
439
+ try {
440
+ await upstreamResponse.body?.cancel();
441
+ } catch {
442
+ // Best-effort cancellation after a hard size cap decision.
443
+ }
444
+ }
445
+
446
+ async function cancelReader(reader) {
447
+ try {
448
+ await reader.cancel();
449
+ } catch {
450
+ // Best-effort cancellation after a hard size cap decision.
451
+ }
452
+ }
453
+
454
+ async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked }) {
455
+ if (typeof runtime.auditSink?.record !== "function") {
456
+ return;
457
+ }
458
+
459
+ await runtime.auditSink.record({
460
+ id: randomUUID(),
461
+ timestamp: new Date().toISOString(),
462
+ protocol: routeContext?.protocol ?? "proxy",
463
+ operation: routeContext ? `proxy:${routeContext.protocol}:${routeContext.routeId ?? "unknown"}` : "proxy",
464
+ mode: runtime.config.policy.mode ?? runtime.config.mode,
465
+ enforced,
466
+ blocked,
467
+ decision,
468
+ reason,
469
+ routeId: routeContext?.routeId ?? "unknown",
470
+ pathHash: routeContext?.path ? shortHash(routeContext.path) : null,
471
+ summary: {
472
+ detectionCount: 0,
473
+ byType: {},
474
+ byAction: {
475
+ [decision]: 1
476
+ }
477
+ }
478
+ });
479
+ }
480
+
481
+ function isStreamingRequest(value, routeContext = {}) {
482
+ if (!value || typeof value !== "object") {
483
+ return false;
484
+ }
485
+ if (value.stream === true) {
486
+ return true;
487
+ }
488
+ // Routes that stream unless explicitly disabled (e.g. Ollama /api/chat,
489
+ // /api/generate) are treated as streaming whenever stream !== false.
490
+ if (routeContext.streamingByDefault && value.stream !== false) {
491
+ return true;
492
+ }
493
+ return false;
494
+ }
495
+
496
+ function proxyError({ statusCode, errorCode, message }) {
497
+ const error = new Error(message);
498
+ error.statusCode = statusCode;
499
+ error.errorCode = errorCode;
500
+ return error;
501
+ }
502
+
503
+ function assertRelativeProxyTarget(url) {
504
+ const target = String(url ?? "").trim();
505
+ if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(target) || target.startsWith("//")) {
506
+ throw proxyError({
507
+ statusCode: 400,
508
+ errorCode: "haechi_invalid_proxy_target",
509
+ message: "Proxy request target must be origin-form path, not an absolute URL"
510
+ });
511
+ }
512
+ }
513
+
514
+ function shortHash(value) {
515
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
516
+ }
517
+
518
+ export function assertSafeProxyBind({ host = "127.0.0.1", allowRemoteBind = false } = {}) {
519
+ if (allowRemoteBind || isLoopbackHost(host)) {
520
+ return;
521
+ }
522
+
523
+ throw new Error(`Refusing to bind Haechi proxy to non-loopback host ${host}. Use --allow-remote-bind only for explicitly secured environments.`);
524
+ }
525
+
526
+ function isLoopbackHost(host) {
527
+ const normalized = String(host).trim().toLowerCase();
528
+ return normalized === "localhost"
529
+ || normalized === "::1"
530
+ || normalized === "[::1]"
531
+ || normalized === "0:0:0:0:0:0:0:1"
532
+ || normalized === "127.0.0.1"
533
+ || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized);
534
+ }