haechi 1.1.2 → 1.3.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.ko.md +46 -11
- package/README.md +46 -11
- package/SECURITY.md +7 -1
- package/docs/README.md +2 -0
- package/docs/current/compliance-mapping.ko.md +53 -0
- package/docs/current/compliance-mapping.md +53 -0
- package/docs/current/config-version.ko.md +30 -0
- package/docs/current/config-version.md +51 -0
- package/docs/current/configuration.ko.md +165 -9
- package/docs/current/configuration.md +165 -9
- package/docs/current/operations-runbook.ko.md +155 -0
- package/docs/current/operations-runbook.md +241 -0
- package/docs/current/release-process.ko.md +5 -1
- package/docs/current/release-process.md +5 -1
- package/docs/current/risk-register-release-gate.ko.md +5 -3
- package/docs/current/risk-register-release-gate.md +13 -3
- package/docs/current/security-whitepaper.ko.md +102 -0
- package/docs/current/security-whitepaper.md +102 -0
- package/docs/current/shared-responsibility.ko.md +2 -2
- package/docs/current/shared-responsibility.md +2 -2
- package/docs/current/threat-model.ko.md +4 -2
- package/docs/current/threat-model.md +4 -2
- package/examples/local-proxy-demo/README.md +51 -0
- package/examples/local-proxy-demo/demo.mjs +144 -0
- package/examples/local-proxy-demo/demo.tape +19 -0
- package/examples/local-proxy-demo/live-demo.mjs +121 -0
- package/examples/local-proxy-demo/live-demo.tape +25 -0
- package/haechi.config.example.json +20 -3
- package/package.json +7 -2
- package/packages/audit/index.mjs +26 -2
- package/packages/cli/bin/haechi.mjs +57 -10
- package/packages/cli/runtime.mjs +402 -10
- package/packages/core/index.mjs +143 -8
- package/packages/filter/index.mjs +975 -12
- package/packages/metrics/index.mjs +181 -0
- package/packages/privacy-profiles/index.mjs +72 -3
- package/packages/protocol-adapters/index.mjs +99 -1
- package/packages/proxy/index.mjs +525 -40
- package/packages/stream-filter/index.mjs +69 -7
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Live end-to-end demo against a REAL upstream model (vLLM / Ollama / any
|
|
3
|
+
// OpenAI-compatible server). Unlike demo.mjs (which uses a deterministic stub),
|
|
4
|
+
// this proves protection against an actual model: it asks the model to repeat the
|
|
5
|
+
// phone number it was given, and the model can only return the *masked* form —
|
|
6
|
+
// the real number never reached it.
|
|
7
|
+
//
|
|
8
|
+
// HAECHI_LIVE_UPSTREAM=http://127.0.0.1:8000 \
|
|
9
|
+
// HAECHI_LIVE_MODEL="Qwen/Qwen3.6-35B-A3B-FP8" \
|
|
10
|
+
// node examples/local-proxy-demo/live-demo.mjs
|
|
11
|
+
//
|
|
12
|
+
// Defaults: type=vllm-openai. HAECHI_LIVE_TYPE and HAECHI_LIVE_MODEL override.
|
|
13
|
+
// Zero dependencies — only node: builtins + the in-repo haechi packages.
|
|
14
|
+
|
|
15
|
+
import { mkdtemp, readFile } from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { createRuntime } from "../../packages/cli/runtime.mjs";
|
|
20
|
+
import { createHaechiProxy } from "../../packages/proxy/index.mjs";
|
|
21
|
+
import { initLocalKeyFile } from "../../packages/crypto/index.mjs";
|
|
22
|
+
|
|
23
|
+
const B = "\x1b[1m", D = "\x1b[2m", G = "\x1b[32m", Y = "\x1b[33m", C = "\x1b[36m", R = "\x1b[31m", X = "\x1b[0m";
|
|
24
|
+
const rule = () => console.log(D + "─".repeat(64) + X);
|
|
25
|
+
const scene = (n, t) => { console.log(); rule(); console.log(`${B}${C} ${n}. ${t}${X}`); rule(); };
|
|
26
|
+
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
|
|
28
|
+
const UPSTREAM = process.env.HAECHI_LIVE_UPSTREAM;
|
|
29
|
+
const TYPE = process.env.HAECHI_LIVE_TYPE || "vllm-openai";
|
|
30
|
+
const MODEL = process.env.HAECHI_LIVE_MODEL || "Qwen/Qwen3.6-35B-A3B-FP8";
|
|
31
|
+
if (!UPSTREAM) {
|
|
32
|
+
console.error("Set HAECHI_LIVE_UPSTREAM (e.g. http://127.0.0.1:8000) to a reachable OpenAI-compatible server.");
|
|
33
|
+
console.error("For a no-backend reproducible run, use: npm run demo");
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function chat(base, content, extra = {}) {
|
|
38
|
+
const t0 = Date.now();
|
|
39
|
+
const res = await fetch(`${base}/v1/chat/completions`, {
|
|
40
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
41
|
+
body: JSON.stringify({ model: MODEL, max_tokens: 128, temperature: 0,
|
|
42
|
+
// Qwen3 reasoning models: ask for a direct answer (no chain-of-thought) so
|
|
43
|
+
// the demo gets a terse content reply. Ignored by non-reasoning servers.
|
|
44
|
+
chat_template_kwargs: { enable_thinking: false },
|
|
45
|
+
messages: [{ role: "user", content }], ...extra })
|
|
46
|
+
});
|
|
47
|
+
const body = await res.json();
|
|
48
|
+
return { status: res.status, ms: Date.now() - t0, text: body.choices?.[0]?.message?.content ?? body.error?.message ?? "(no content)" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
console.log(`\n${B}🛡 Haechi — LIVE end-to-end demo${X} ${D}(real model: ${MODEL} via ${TYPE}, enforce mode)${X}`);
|
|
53
|
+
|
|
54
|
+
const dir = await mkdtemp(join(tmpdir(), "haechi-live-"));
|
|
55
|
+
const keyFile = join(dir, ".haechi", "dev.keys.json");
|
|
56
|
+
const auditPath = join(dir, ".haechi", "audit.jsonl");
|
|
57
|
+
await initLocalKeyFile(keyFile, { force: true });
|
|
58
|
+
|
|
59
|
+
const runtime = createRuntime({
|
|
60
|
+
mode: "enforce",
|
|
61
|
+
target: { type: TYPE, upstream: UPSTREAM },
|
|
62
|
+
policy: { mode: "enforce", presets: ["llm-redact"], actions: { email: "tokenize", phone: "mask", secret: "redact", api_key: "redact", card: "block" } },
|
|
63
|
+
tokenVault: { detokenizeResponses: true },
|
|
64
|
+
responseProtection: { enabled: true, mode: "enforce", failureMode: "fail-closed" },
|
|
65
|
+
keys: { keyFile }, audit: { path: auditPath }
|
|
66
|
+
});
|
|
67
|
+
const proxy = createHaechiProxy({ runtime, port: 0 });
|
|
68
|
+
const addr = await proxy.listen();
|
|
69
|
+
const base = `http://127.0.0.1:${addr.port}`;
|
|
70
|
+
|
|
71
|
+
// ── Scene 1 ────────────────────────────────────────────────────────────────
|
|
72
|
+
scene(1, "Ask a REAL model to repeat the phone number you give it");
|
|
73
|
+
const prompt = "Reply in one short line: repeat the phone number you were given. Phone: 010-1234-5678, email minji.kim@example.com";
|
|
74
|
+
console.log(`${Y}you send →${X} ${prompt}`);
|
|
75
|
+
await pause(700);
|
|
76
|
+
const r1 = await chat(base, prompt);
|
|
77
|
+
|
|
78
|
+
scene(2, "Haechi detected + protected the prompt BEFORE it left your machine");
|
|
79
|
+
const events = (await readFile(auditPath, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
|
|
80
|
+
const ev = events.find((e) => Array.isArray(e.detections) && e.detections.length) ?? events[0];
|
|
81
|
+
console.log(`${D}detections:${X} ${(ev.detections ?? []).map((d) => `${G}${d.type}→${d.action}${X}`).join(" ")}`);
|
|
82
|
+
console.log(`${D}the model only ever saw:${X} email → ${C}[TOKEN:…]${X}, phone → ${C}01*********78${X}`);
|
|
83
|
+
await pause(700);
|
|
84
|
+
|
|
85
|
+
scene(3, "The real model replies — it can only return the MASKED phone");
|
|
86
|
+
console.log(`${G}${MODEL.split("/").pop()} →${X} ${B}${r1.text}${X} ${D}(${r1.ms} ms)${X}`);
|
|
87
|
+
console.log(`${D} your real number 010-1234-5678 never reached the model — it cannot reveal it.${X}`);
|
|
88
|
+
await pause(700);
|
|
89
|
+
|
|
90
|
+
// ── Scene 4 ────────────────────────────────────────────────────────────────
|
|
91
|
+
scene(4, "The audit log — hash-chained, and never any plaintext");
|
|
92
|
+
const auditRaw = await readFile(auditPath, "utf8");
|
|
93
|
+
console.log(`${D}leaks the real email/phone?${X} ${/minji\.kim@example|010-1234-5678/.test(auditRaw) ? R + "YES" + X : G + "no — clean" + X}`);
|
|
94
|
+
await pause(700);
|
|
95
|
+
|
|
96
|
+
// ── Scene 5 ────────────────────────────────────────────────────────────────
|
|
97
|
+
scene(5, "Day-2 operability — live readiness + Prometheus metrics");
|
|
98
|
+
const ready = await (await fetch(`${base}/__haechi/ready`)).json();
|
|
99
|
+
console.log(`${D}/__haechi/ready →${X} ${ready.ready ? G + "ready" : R + "not ready"}${X}`);
|
|
100
|
+
const metrics = await (await fetch(`${base}/__haechi/metrics`)).text();
|
|
101
|
+
for (const line of metrics.split("\n").filter((l) => /^haechi_requests_total\{/.test(l)).slice(0, 3)) {
|
|
102
|
+
console.log(`${D}metric:${X} ${line}`);
|
|
103
|
+
}
|
|
104
|
+
await pause(700);
|
|
105
|
+
|
|
106
|
+
// ── Scene 6 ────────────────────────────────────────────────────────────────
|
|
107
|
+
scene(6, "A card number is blocked before it ever reaches the model");
|
|
108
|
+
const r2 = await chat(base, "charge card 4242 4242 4242 4242 now");
|
|
109
|
+
console.log(`${Y}you send →${X} "charge card 4242 4242 4242 4242 now"`);
|
|
110
|
+
console.log(`${G}proxy →${X} HTTP ${r2.status} ${r2.status === 403 ? R + B + "BLOCKED" + X : ""} ${D}(no upstream call made)${X}`);
|
|
111
|
+
|
|
112
|
+
console.log();
|
|
113
|
+
rule();
|
|
114
|
+
console.log(`${B}${G} ✓ live${X} ${D}— a real model, and your PII never left the gateway in the clear.${X}`);
|
|
115
|
+
rule();
|
|
116
|
+
console.log();
|
|
117
|
+
|
|
118
|
+
await proxy.close();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
main().then(() => process.exit(0)).catch((e) => { console.error("live demo failed:", e); process.exit(1); });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# VHS tape for the Haechi LIVE demo (real upstream model).
|
|
2
|
+
# Regenerate the README GIF with:
|
|
3
|
+
# HAECHI_LIVE_UPSTREAM is set below via Env so it stays out of the recording.
|
|
4
|
+
# vhs examples/local-proxy-demo/live-demo.tape (run from the repo root)
|
|
5
|
+
|
|
6
|
+
Output docs/assets/haechi-demo.gif
|
|
7
|
+
|
|
8
|
+
Set Shell "bash"
|
|
9
|
+
Set FontSize 15
|
|
10
|
+
Set Width 1180
|
|
11
|
+
Set Height 840
|
|
12
|
+
Set Padding 18
|
|
13
|
+
Set Theme "Catppuccin Mocha"
|
|
14
|
+
Set TypingSpeed 55ms
|
|
15
|
+
|
|
16
|
+
# Point these at a reachable OpenAI-compatible server before recording. Using Env
|
|
17
|
+
# (not the typed command) keeps the upstream URL out of the captured GIF.
|
|
18
|
+
Env HAECHI_LIVE_UPSTREAM "http://127.0.0.1:8000"
|
|
19
|
+
Env HAECHI_LIVE_MODEL "Qwen/Qwen3.6-35B-A3B-FP8"
|
|
20
|
+
|
|
21
|
+
Sleep 500ms
|
|
22
|
+
Type "node examples/local-proxy-demo/live-demo.mjs"
|
|
23
|
+
Sleep 600ms
|
|
24
|
+
Enter
|
|
25
|
+
Sleep 9s
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"configVersion": 1,
|
|
2
3
|
"mode": "dry-run",
|
|
3
4
|
"target": {
|
|
4
5
|
"type": "llm-http",
|
|
@@ -7,7 +8,9 @@
|
|
|
7
8
|
},
|
|
8
9
|
"proxy": {
|
|
9
10
|
"host": "127.0.0.1",
|
|
10
|
-
"port": 11016
|
|
11
|
+
"port": 11016,
|
|
12
|
+
"tls": null,
|
|
13
|
+
"trustForwardedProto": false
|
|
11
14
|
},
|
|
12
15
|
"responseProtection": {
|
|
13
16
|
"enabled": false,
|
|
@@ -25,7 +28,12 @@
|
|
|
25
28
|
},
|
|
26
29
|
"limits": {
|
|
27
30
|
"maxRequestBytes": 1048576,
|
|
28
|
-
"
|
|
31
|
+
"maxNestingDepth": 256,
|
|
32
|
+
"upstreamTimeoutMs": 120000,
|
|
33
|
+
"maxInFlight": 0,
|
|
34
|
+
"shutdownGraceMs": 10000,
|
|
35
|
+
"requestTimeoutMs": null,
|
|
36
|
+
"headersTimeoutMs": null
|
|
29
37
|
},
|
|
30
38
|
"policy": {
|
|
31
39
|
"mode": "dry-run",
|
|
@@ -40,7 +48,10 @@
|
|
|
40
48
|
}
|
|
41
49
|
},
|
|
42
50
|
"filters": {
|
|
43
|
-
"customRules": []
|
|
51
|
+
"customRules": [],
|
|
52
|
+
"minConfidence": 0,
|
|
53
|
+
"allowlist": [],
|
|
54
|
+
"decodeAndRescan": false
|
|
44
55
|
},
|
|
45
56
|
"keys": {
|
|
46
57
|
"provider": "local",
|
|
@@ -67,6 +78,12 @@
|
|
|
67
78
|
"privacy": {
|
|
68
79
|
"profile": null
|
|
69
80
|
},
|
|
81
|
+
"logging": {
|
|
82
|
+
"format": "text"
|
|
83
|
+
},
|
|
84
|
+
"metrics": {
|
|
85
|
+
"enabled": true
|
|
86
|
+
},
|
|
70
87
|
"auth": {
|
|
71
88
|
"provider": "none",
|
|
72
89
|
"store": ".haechi/auth.json",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haechi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic — a stable, zero-dependency security gateway.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"./token-vault": "./packages/token-vault/index.mjs",
|
|
52
52
|
"./stream-filter": "./packages/stream-filter/index.mjs",
|
|
53
53
|
"./auth": "./packages/auth/index.mjs",
|
|
54
|
-
"./ssrf": "./packages/ssrf/index.mjs"
|
|
54
|
+
"./ssrf": "./packages/ssrf/index.mjs",
|
|
55
|
+
"./metrics": "./packages/metrics/index.mjs"
|
|
55
56
|
},
|
|
56
57
|
"files": [
|
|
57
58
|
"README.md",
|
|
@@ -74,10 +75,14 @@
|
|
|
74
75
|
"sbom": "node scripts/generate-sbom.mjs",
|
|
75
76
|
"checksums": "node scripts/release-checksums.mjs",
|
|
76
77
|
"bench:payload": "node scripts/bench-payload.mjs",
|
|
78
|
+
"bench:detection": "node scripts/bench-detection.mjs",
|
|
79
|
+
"bench:throughput": "node scripts/bench-throughput.mjs",
|
|
80
|
+
"scan:detection": "node scripts/bench-detection.mjs --gate",
|
|
77
81
|
"check:peer-ranges": "node scripts/check-satellite-peer-ranges.mjs",
|
|
78
82
|
"release:preflight": "node scripts/release-preflight.mjs && node scripts/check-satellite-peer-ranges.mjs",
|
|
79
83
|
"release:preflight:npm": "node scripts/release-preflight.mjs --require-npm-auth && node scripts/check-satellite-peer-ranges.mjs",
|
|
80
84
|
"haechi": "node packages/cli/bin/haechi.mjs",
|
|
85
|
+
"demo": "node examples/local-proxy-demo/demo.mjs",
|
|
81
86
|
"demo:init": "node packages/cli/bin/haechi.mjs init --force",
|
|
82
87
|
"demo:protect": "node packages/cli/bin/haechi.mjs protect examples/llm-prompt-filtering/input.json --config haechi.config.json",
|
|
83
88
|
"demo:report": "node packages/cli/bin/haechi.mjs report --audit .haechi/audit.jsonl"
|
package/packages/audit/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createReadStream } from "node:fs";
|
|
2
|
-
import { appendFile, mkdir, open, stat, unlink } from "node:fs/promises";
|
|
1
|
+
import { createReadStream, constants as fsConstants } from "node:fs";
|
|
2
|
+
import { access, appendFile, mkdir, open, stat, unlink } from "node:fs/promises";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
@@ -91,6 +91,30 @@ export function createJsonlAuditSink({ path, anchor = null }) {
|
|
|
91
91
|
});
|
|
92
92
|
writeQueue = write.catch(() => {});
|
|
93
93
|
await write;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// WS4-A readiness probe: a CHEAP writability check used by /__haechi/ready.
|
|
97
|
+
// A security gateway that cannot append to its audit log is NOT ready
|
|
98
|
+
// (fail-closed), so this confirms the audit directory exists and is writable
|
|
99
|
+
// WITHOUT writing an event (no audit-chain side effect). It returns the bare
|
|
100
|
+
// boolean and an enum reason — never a path value or any payload/PII.
|
|
101
|
+
async ready() {
|
|
102
|
+
try {
|
|
103
|
+
const dir = dirname(path);
|
|
104
|
+
await mkdir(dir, { recursive: true });
|
|
105
|
+
await access(dir, fsConstants.W_OK);
|
|
106
|
+
// If the audit file already exists, confirm it is writable too.
|
|
107
|
+
try {
|
|
108
|
+
await access(path, fsConstants.W_OK);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code !== "ENOENT") {
|
|
111
|
+
return { ok: false, reason: "audit_file_not_writable" };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ok: true };
|
|
115
|
+
} catch {
|
|
116
|
+
return { ok: false, reason: "audit_dir_not_writable" };
|
|
117
|
+
}
|
|
94
118
|
}
|
|
95
119
|
};
|
|
96
120
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { readAuditSummary, verifyAuditChain } from "../../audit/index.mjs";
|
|
4
|
-
import { DEFAULT_PROXY_PORT, createHaechiProxy } from "../../proxy/index.mjs";
|
|
4
|
+
import { DEFAULT_PROXY_PORT, HAECHI_VERSION, createHaechiProxy } from "../../proxy/index.mjs";
|
|
5
5
|
import { signPolicyBundleFile, verifyPolicyBundleFile } from "../../policy-bundle/index.mjs";
|
|
6
6
|
import { validatePluginManifestFile } from "../../plugin/index.mjs";
|
|
7
7
|
import { runMcpStdioFilter, wrapMcpChild } from "../../mcp-stdio/index.mjs";
|
|
@@ -283,25 +283,69 @@ async function proxyCommand(argv) {
|
|
|
283
283
|
const port = parsePort(options.port ?? config.proxy.port);
|
|
284
284
|
const host = options.host ?? config.proxy.host;
|
|
285
285
|
const allowRemoteBind = Boolean(options["allow-remote-bind"]);
|
|
286
|
+
// proxy.tls / proxy.trustForwardedProto come from the normalized config (the
|
|
287
|
+
// TLS material is loaded from file paths at load time); createHaechiProxy reads
|
|
288
|
+
// them from runtime.config.proxy, so the CLI does not re-pass them. The bind
|
|
289
|
+
// guard inside createHaechiProxy throws fail-closed for a remote bind without
|
|
290
|
+
// TLS and without trustForwardedProto.
|
|
286
291
|
const proxy = createHaechiProxy({ runtime, port, host, allowRemoteBind });
|
|
287
292
|
const address = await proxy.listen();
|
|
293
|
+
const scheme = address.tls ? "https" : "http";
|
|
288
294
|
|
|
289
295
|
const effectiveMode = config.policy.mode ?? config.mode;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
296
|
+
const jsonLogs = config.logging?.format === "json";
|
|
297
|
+
// Structured startup/shutdown logs honor logging.format. JSON mode emits one
|
|
298
|
+
// line per event carrying only non-secret operational fields (host/port/mode/
|
|
299
|
+
// version/warning codes) — never a payload, token, or PII value.
|
|
300
|
+
const logEvent = (level, event, fields = {}) => {
|
|
301
|
+
if (jsonLogs) {
|
|
302
|
+
const stream = level === "warn" ? process.stderr : process.stdout;
|
|
303
|
+
stream.write(`${JSON.stringify({ level, event, ...fields })}\n`);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
if (jsonLogs) {
|
|
308
|
+
logEvent("info", "proxy_listening", {
|
|
309
|
+
host: address.host,
|
|
310
|
+
port: address.port,
|
|
311
|
+
scheme,
|
|
312
|
+
tls: Boolean(address.tls),
|
|
313
|
+
upstream: config.target.upstream,
|
|
314
|
+
mode: effectiveMode,
|
|
315
|
+
version: HAECHI_VERSION
|
|
316
|
+
});
|
|
317
|
+
} else {
|
|
318
|
+
console.log(`Haechi proxy listening on ${scheme}://${address.host}:${address.port}`);
|
|
319
|
+
console.log(`Upstream: ${config.target.upstream}`);
|
|
320
|
+
console.log(`Mode: ${effectiveMode}`);
|
|
321
|
+
}
|
|
293
322
|
if (allowRemoteBind) {
|
|
294
|
-
|
|
323
|
+
if (jsonLogs) {
|
|
324
|
+
logEvent("warn", "remote_bind_enabled", { tls: Boolean(address.tls), trustForwardedProto: Boolean(config.proxy?.trustForwardedProto) });
|
|
325
|
+
} else if (address.tls) {
|
|
326
|
+
console.error("warning: --allow-remote-bind exposes the proxy beyond loopback (TLS terminated by Haechi). Put Haechi behind explicit network access controls.");
|
|
327
|
+
} else {
|
|
328
|
+
console.error("warning: --allow-remote-bind exposes the proxy beyond loopback behind a trusted TLS-terminating reverse proxy (proxy.trustForwardedProto). Requests without X-Forwarded-Proto: https are refused. Put Haechi behind explicit network access controls.");
|
|
329
|
+
}
|
|
295
330
|
}
|
|
296
331
|
if (effectiveMode !== "enforce") {
|
|
297
|
-
|
|
332
|
+
if (jsonLogs) {
|
|
333
|
+
logEvent("warn", "non_enforce_mode", { mode: effectiveMode });
|
|
334
|
+
} else {
|
|
335
|
+
console.error(`warning: policy mode is ${effectiveMode}. Payloads are inspected and audited but NOT modified or blocked. Set policy.mode to "enforce" to protect traffic.`);
|
|
336
|
+
}
|
|
298
337
|
}
|
|
299
338
|
if (!config.responseProtection.enabled) {
|
|
300
|
-
|
|
339
|
+
if (jsonLogs) {
|
|
340
|
+
logEvent("warn", "response_protection_disabled");
|
|
341
|
+
} else {
|
|
342
|
+
console.error("warning: responseProtection.enabled is false. Upstream responses are forwarded without inspection.");
|
|
343
|
+
}
|
|
301
344
|
}
|
|
302
345
|
|
|
303
346
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
304
347
|
process.once(signal, async () => {
|
|
348
|
+
logEvent("info", "proxy_shutdown", { signal });
|
|
305
349
|
await proxy.close();
|
|
306
350
|
process.exit(0);
|
|
307
351
|
});
|
|
@@ -599,7 +643,7 @@ const COMMAND_HELP = {
|
|
|
599
643
|
proxy: {
|
|
600
644
|
usage: `haechi proxy [--config haechi.config.json] [--host 127.0.0.1] [--port ${DEFAULT_PROXY_PORT}] [--allow-remote-bind]`,
|
|
601
645
|
summary: "Run the local HTTP JSON proxy in front of an upstream LLM.",
|
|
602
|
-
detail: "Binds loopback by default; --allow-remote-bind is required (and must be a CLI flag, not config) to bind non-loopback hosts.
|
|
646
|
+
detail: "Binds loopback (plain http) by default; --allow-remote-bind is required (and must be a CLI flag, not config) to bind non-loopback hosts. A remote bind additionally requires TLS: set proxy.tls ({ keyFile, certFile } or { pfxFile, passphrase? }) so Haechi serves https, OR set proxy.trustForwardedProto: true when a trusted reverse proxy terminates TLS in front of Haechi (Haechi then refuses any request without X-Forwarded-Proto: https). Configure client auth via auth.provider — see 'haechi config'."
|
|
603
647
|
},
|
|
604
648
|
"policy-sign": {
|
|
605
649
|
usage: "haechi policy-sign <policy.json> [--config haechi.config.json] [--out policy.bundle.json]",
|
|
@@ -693,7 +737,8 @@ Enforcement
|
|
|
693
737
|
|
|
694
738
|
Upstream + proxy
|
|
695
739
|
target.type llm-http | openai-compatible | vllm-openai |
|
|
696
|
-
ollama | llama-cpp
|
|
740
|
+
ollama | llama-cpp | anthropic |
|
|
741
|
+
gemini (unknown = fail)
|
|
697
742
|
target.upstream the only upstream the proxy forwards to
|
|
698
743
|
proxy.host / proxy.port 127.0.0.1 / ${DEFAULT_PROXY_PORT}
|
|
699
744
|
non-loopback host needs --allow-remote-bind (CLI flag)
|
|
@@ -712,6 +757,8 @@ Detection policy
|
|
|
712
757
|
policy.defaultAction allow | redact | mask | tokenize | encrypt | block
|
|
713
758
|
policy.actions per-type overrides; merges may strengthen, not weaken
|
|
714
759
|
filters.customRules extra regex rules (ReDoS-screened)
|
|
760
|
+
filters.minConfidence [0,1] drop soft detections below this (not hard-block)
|
|
761
|
+
filters.allowlist FP exceptions [value|{value?,path?}] (not hard-block)
|
|
715
762
|
|
|
716
763
|
Tokenization (model sees token, caller sees plaintext)
|
|
717
764
|
tokenVault.revealPolicy disabled | local-dev (manual reveal gate)
|
|
@@ -731,7 +778,7 @@ Audit integrity
|
|
|
731
778
|
audit.anchor.everyRecords anchor cadence (default 1)
|
|
732
779
|
|
|
733
780
|
Privacy + MCP
|
|
734
|
-
privacy.profile kr-pipa | eu-gdpr | us-general | null
|
|
781
|
+
privacy.profile kr-pipa | eu-gdpr | asia-pdpa | us-general | jp-appi | null
|
|
735
782
|
mcp.allowedMethods client-callable method allowlist
|
|
736
783
|
|
|
737
784
|
Binding beyond loopback (0.0.0.0):
|