haechi 1.2.0 → 1.3.1
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 +57 -11
- package/README.md +57 -11
- package/docs/current/code-review-risk-register-2026-06-16.ko.md +377 -0
- package/docs/current/code-review-risk-register-2026-06-16.md +377 -0
- package/docs/current/config-version.ko.md +2 -2
- package/docs/current/config-version.md +2 -2
- package/docs/current/configuration.ko.md +28 -11
- package/docs/current/configuration.md +28 -11
- package/docs/current/operations-runbook.ko.md +36 -2
- package/docs/current/operations-runbook.md +39 -2
- 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 +34 -8
- package/docs/current/risk-register-release-gate.md +34 -8
- package/docs/current/shared-responsibility.ko.md +12 -3
- package/docs/current/shared-responsibility.md +12 -3
- package/docs/current/threat-model.ko.md +7 -3
- package/docs/current/threat-model.md +7 -3
- 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 +2 -1
- package/package.json +3 -1
- package/packages/cli/bin/haechi.mjs +95 -5
- package/packages/cli/runtime.mjs +61 -1
- package/packages/core/index.mjs +15 -0
- package/packages/crypto/index.mjs +42 -20
- package/packages/filter/index.mjs +679 -6
- package/packages/privacy-profiles/index.mjs +72 -3
- package/packages/protocol-adapters/index.mjs +99 -1
- package/packages/proxy/index.mjs +270 -29
- package/packages/ssrf/index.mjs +60 -4
- package/packages/stream-filter/index.mjs +194 -17
|
@@ -549,6 +549,8 @@ async function mcpStdioCommand(argv) {
|
|
|
549
549
|
await runMcpStdioFilter({ runtime });
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
const STDERR_MODES = new Set(["filter", "drop", "inherit"]);
|
|
553
|
+
|
|
552
554
|
async function mcpWrapCommand(argv) {
|
|
553
555
|
const separator = argv.indexOf("--");
|
|
554
556
|
if (separator === -1 || !argv[separator + 1]) {
|
|
@@ -558,21 +560,108 @@ async function mcpWrapCommand(argv) {
|
|
|
558
560
|
const command = argv[separator + 1];
|
|
559
561
|
const commandArgs = argv.slice(separator + 2);
|
|
560
562
|
|
|
563
|
+
// --stderr controls how the child's stderr crosses the local-process boundary.
|
|
564
|
+
// filter (default) runs each line through the same Haechi protection as MCP
|
|
565
|
+
// traffic before re-emitting; drop discards it; inherit is the raw passthrough
|
|
566
|
+
// (an explicit, opt-in local-process boundary). Unknown values fail closed.
|
|
567
|
+
const stderrMode = options.stderr === undefined ? "filter" : options.stderr;
|
|
568
|
+
if (!STDERR_MODES.has(stderrMode)) {
|
|
569
|
+
throw new Error(`mcp-wrap --stderr must be one of: filter | drop | inherit (got ${JSON.stringify(stderrMode)})`);
|
|
570
|
+
}
|
|
571
|
+
|
|
561
572
|
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
562
573
|
const runtime = createRuntime(config);
|
|
563
574
|
|
|
575
|
+
// "inherit" hands the child's stderr straight to the terminal (raw, unfiltered);
|
|
576
|
+
// filter/drop pipe it so the wrapper can inspect or discard each line.
|
|
564
577
|
const child = spawn(command, commandArgs, {
|
|
565
|
-
stdio: ["pipe", "pipe", "inherit"]
|
|
578
|
+
stdio: ["pipe", "pipe", stderrMode === "inherit" ? "inherit" : "pipe"]
|
|
566
579
|
});
|
|
567
580
|
|
|
568
581
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
569
582
|
process.on(signal, () => child.kill(signal));
|
|
570
583
|
}
|
|
571
584
|
|
|
585
|
+
if (stderrMode === "filter") {
|
|
586
|
+
pipeFilteredStderr({ runtime, child });
|
|
587
|
+
} else if (stderrMode === "drop") {
|
|
588
|
+
// Consume so the child's stderr pipe never fills and stalls the child, but
|
|
589
|
+
// re-emit nothing.
|
|
590
|
+
child.stderr?.resume();
|
|
591
|
+
}
|
|
592
|
+
|
|
572
593
|
const { code } = await wrapMcpChild({ runtime, child });
|
|
573
594
|
process.exitCode = code;
|
|
574
595
|
}
|
|
575
596
|
|
|
597
|
+
// Filter the child's stderr through the SAME protection the wrapper applies to
|
|
598
|
+
// MCP traffic, then re-emit each safe line to the parent process.stderr. Each
|
|
599
|
+
// complete line is protected as text via the runtime's haechi instance (redact/
|
|
600
|
+
// mask rewrite detected secrets/PII in place); a block-action detection drops the
|
|
601
|
+
// line entirely. Partial lines are buffered across chunk boundaries (split on \n;
|
|
602
|
+
// hold the trailing partial, flushed on stream end).
|
|
603
|
+
function pipeFilteredStderr({ runtime, child, stderr = process.stderr }) {
|
|
604
|
+
const source = child.stderr;
|
|
605
|
+
if (!source) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
source.setEncoding("utf8");
|
|
609
|
+
let buffer = "";
|
|
610
|
+
// Serialize async protection so lines re-emit in source order even though
|
|
611
|
+
// protectStderrLine is async.
|
|
612
|
+
let queue = Promise.resolve();
|
|
613
|
+
|
|
614
|
+
function enqueue(line) {
|
|
615
|
+
queue = queue.then(async () => {
|
|
616
|
+
const safe = await protectStderrLine(runtime, line);
|
|
617
|
+
if (safe !== null) {
|
|
618
|
+
stderr.write(`${safe}\n`);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
source.on("data", (chunk) => {
|
|
624
|
+
buffer += chunk;
|
|
625
|
+
let index;
|
|
626
|
+
while ((index = buffer.indexOf("\n")) !== -1) {
|
|
627
|
+
const line = buffer.slice(0, index);
|
|
628
|
+
buffer = buffer.slice(index + 1);
|
|
629
|
+
enqueue(line);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
source.on("end", () => {
|
|
633
|
+
// Flush any trailing partial line (no terminating newline).
|
|
634
|
+
if (buffer.length > 0) {
|
|
635
|
+
enqueue(buffer);
|
|
636
|
+
buffer = "";
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Protect one stderr line as text. Returns the protected line (detected secrets/
|
|
642
|
+
// PII redacted/masked in place), or null when a block-action detection means the
|
|
643
|
+
// line must be dropped (not emitted). Uses the runtime's haechi stream/text
|
|
644
|
+
// protector — the clean single-shot text entrypoint (protectText) that detects,
|
|
645
|
+
// decides, and transforms a complete, self-contained text segment by offset, the
|
|
646
|
+
// same logic the streaming delta channel commits with. A fresh protector per line
|
|
647
|
+
// keeps no cross-line state (we already split on \n and buffer partials above).
|
|
648
|
+
async function protectStderrLine(runtime, line) {
|
|
649
|
+
if (line.length === 0) {
|
|
650
|
+
return line;
|
|
651
|
+
}
|
|
652
|
+
const protector = runtime.haechi.createStreamProtector({
|
|
653
|
+
protocol: "mcp-stdio",
|
|
654
|
+
operation: "stderr",
|
|
655
|
+
direction: "response",
|
|
656
|
+
mode: runtime.config.policy.mode ?? runtime.config.mode
|
|
657
|
+
});
|
|
658
|
+
const result = await protector.protectText(line);
|
|
659
|
+
if (result.blocked) {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
return result.text;
|
|
663
|
+
}
|
|
664
|
+
|
|
576
665
|
function parseOptions(argv) {
|
|
577
666
|
const options = {};
|
|
578
667
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -675,9 +764,9 @@ const COMMAND_HELP = {
|
|
|
675
764
|
summary: "Filter MCP JSON-RPC traffic on stdin/stdout (one direction)."
|
|
676
765
|
},
|
|
677
766
|
"mcp-wrap": {
|
|
678
|
-
usage: "haechi mcp-wrap [--config haechi.config.json] -- <command> [args...]",
|
|
767
|
+
usage: "haechi mcp-wrap [--config haechi.config.json] [--stderr filter|drop|inherit] -- <command> [args...]",
|
|
679
768
|
summary: "Wrap an MCP server with bidirectional stdio protection.",
|
|
680
|
-
detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs."
|
|
769
|
+
detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs. --stderr controls the child's stderr: filter (default) protects each line with the same policy before re-emitting, drop discards it, inherit passes it through raw (an explicit, opt-in local-process boundary). filter follows the configured policy mode — in dry-run/report-only it detects but does not transform (like the rest of the pipeline), so set policy.mode=enforce for stderr redaction to take effect."
|
|
681
770
|
},
|
|
682
771
|
auth: {
|
|
683
772
|
usage: "haechi auth add --type user|service|agent [--scope k:v ...] [--label k=v ...]\n haechi auth list [--config haechi.config.json]\n haechi auth revoke <id> [--config haechi.config.json]",
|
|
@@ -737,7 +826,8 @@ Enforcement
|
|
|
737
826
|
|
|
738
827
|
Upstream + proxy
|
|
739
828
|
target.type llm-http | openai-compatible | vllm-openai |
|
|
740
|
-
ollama | llama-cpp
|
|
829
|
+
ollama | llama-cpp | anthropic |
|
|
830
|
+
gemini (unknown = fail)
|
|
741
831
|
target.upstream the only upstream the proxy forwards to
|
|
742
832
|
proxy.host / proxy.port 127.0.0.1 / ${DEFAULT_PROXY_PORT}
|
|
743
833
|
non-loopback host needs --allow-remote-bind (CLI flag)
|
|
@@ -777,7 +867,7 @@ Audit integrity
|
|
|
777
867
|
audit.anchor.everyRecords anchor cadence (default 1)
|
|
778
868
|
|
|
779
869
|
Privacy + MCP
|
|
780
|
-
privacy.profile kr-pipa | eu-gdpr | us-general | null
|
|
870
|
+
privacy.profile kr-pipa | eu-gdpr | asia-pdpa | us-general | jp-appi | null
|
|
781
871
|
mcp.allowedMethods client-callable method allowlist
|
|
782
872
|
|
|
783
873
|
Binding beyond loopback (0.0.0.0):
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -103,7 +103,13 @@ export function defaultConfig() {
|
|
|
103
103
|
// allowlist [] = no operator FP exceptions. Both additive; neither can
|
|
104
104
|
// suppress a hard-block type (secret/api_key/kr_rrn/card) — see core.
|
|
105
105
|
minConfidence: 0,
|
|
106
|
-
allowlist: []
|
|
106
|
+
allowlist: [],
|
|
107
|
+
// WS2d residual — opt-in base64/percent decode-and-rescan. Default false =
|
|
108
|
+
// byte-identical to prior behavior (no decode). When true, a string leaf
|
|
109
|
+
// that looks base64/percent-encoded is decoded and rescanned; a decoded
|
|
110
|
+
// hit fails closed to a WHOLE-LEAF detection and only fires for a validator-
|
|
111
|
+
// backed / hard-block match (precision guard against random-base64 FPs).
|
|
112
|
+
decodeAndRescan: false
|
|
107
113
|
},
|
|
108
114
|
keys: {
|
|
109
115
|
provider: "local",
|
|
@@ -592,6 +598,7 @@ export function normalizeConfig(config) {
|
|
|
592
598
|
if (merged.auth.provider === "plugin") {
|
|
593
599
|
validatePluginAuthConfig(merged);
|
|
594
600
|
}
|
|
601
|
+
validateForwardHeaders(merged.target);
|
|
595
602
|
createProtocolAdapter(merged.target);
|
|
596
603
|
return merged;
|
|
597
604
|
}
|
|
@@ -716,6 +723,11 @@ function validateFilters(filters) {
|
|
|
716
723
|
}
|
|
717
724
|
}
|
|
718
725
|
}
|
|
726
|
+
// WS2d residual — opt-in base64/percent decode-and-rescan. Strict boolean,
|
|
727
|
+
// fail-closed: a non-boolean throws rather than silently coercing.
|
|
728
|
+
if (filters.decodeAndRescan !== undefined && typeof filters.decodeAndRescan !== "boolean") {
|
|
729
|
+
throw new Error("filters.decodeAndRescan must be a boolean");
|
|
730
|
+
}
|
|
719
731
|
}
|
|
720
732
|
|
|
721
733
|
function validatePolicyExtras(policy) {
|
|
@@ -925,6 +937,54 @@ function validatePluginAuthConfig(merged) {
|
|
|
925
937
|
}
|
|
926
938
|
}
|
|
927
939
|
|
|
940
|
+
// P0-CR-001 — additive escape hatch for an unusual upstream that needs a header
|
|
941
|
+
// the built-in allowlist does not cover. `target.forwardHeaders` is an OPTIONAL
|
|
942
|
+
// array of extra lowercase header NAMES to forward to the upstream. Fail-closed:
|
|
943
|
+
// it must be an array of non-empty strings, and it may NOT name a header that the
|
|
944
|
+
// proxy always drops (ambient client credentials + hop-by-hop control headers) —
|
|
945
|
+
// an operator cannot re-enable a gateway-credential leak through it. Absent =
|
|
946
|
+
// the built-in default-drop allowlist alone (byte-identical to prior behavior).
|
|
947
|
+
const FORWARD_HEADERS_FORBIDDEN = new Set([
|
|
948
|
+
"host",
|
|
949
|
+
"content-length",
|
|
950
|
+
"content-type",
|
|
951
|
+
"authorization",
|
|
952
|
+
"cookie",
|
|
953
|
+
"set-cookie",
|
|
954
|
+
"proxy-authorization",
|
|
955
|
+
"connection",
|
|
956
|
+
"keep-alive",
|
|
957
|
+
"te",
|
|
958
|
+
"trailer",
|
|
959
|
+
"transfer-encoding",
|
|
960
|
+
"upgrade"
|
|
961
|
+
]);
|
|
962
|
+
|
|
963
|
+
function validateForwardHeaders(target) {
|
|
964
|
+
if (target.forwardHeaders === undefined || target.forwardHeaders === null) {
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (!Array.isArray(target.forwardHeaders)) {
|
|
968
|
+
throw new Error("target.forwardHeaders must be an array of lowercase header names");
|
|
969
|
+
}
|
|
970
|
+
const normalized = [];
|
|
971
|
+
for (const name of target.forwardHeaders) {
|
|
972
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
973
|
+
throw new Error("target.forwardHeaders entries must be non-empty strings");
|
|
974
|
+
}
|
|
975
|
+
const lower = name.trim().toLowerCase();
|
|
976
|
+
if (lower !== name) {
|
|
977
|
+
throw new Error(`target.forwardHeaders entries must be lowercase header names (got: ${JSON.stringify(name)})`);
|
|
978
|
+
}
|
|
979
|
+
if (FORWARD_HEADERS_FORBIDDEN.has(lower)) {
|
|
980
|
+
throw new Error(`target.forwardHeaders may not include the always-dropped header ${JSON.stringify(lower)} (ambient credentials and hop-by-hop headers are never forwarded)`);
|
|
981
|
+
}
|
|
982
|
+
normalized.push(lower);
|
|
983
|
+
}
|
|
984
|
+
// Persist the validated, de-duplicated list back onto the normalized target.
|
|
985
|
+
target.forwardHeaders = [...new Set(normalized)];
|
|
986
|
+
}
|
|
987
|
+
|
|
928
988
|
function resolveAuthProvider(config, providers, cryptoProvider, auditSink) {
|
|
929
989
|
if (config.auth.provider === "external") {
|
|
930
990
|
if (typeof providers.authProvider?.authenticate !== "function") {
|
package/packages/core/index.mjs
CHANGED
|
@@ -148,6 +148,21 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
return {
|
|
151
|
+
// Single-shot text protection for a complete, self-contained text payload
|
|
152
|
+
// (P1-CR-005): a parse-failed CONTENT frame whose data: text is NOT JSON
|
|
153
|
+
// (plain text, malformed/partial JSON, provider-specific text). It detects,
|
|
154
|
+
// decides, tallies, and either returns { text } or { blocked: true } — the
|
|
155
|
+
// SAME transformSegment logic the delta channel commits with. CRITICALLY it
|
|
156
|
+
// does NOT touch the cross-frame `pending` buffer, so inspecting a non-JSON
|
|
157
|
+
// frame's text cannot corrupt the JSON delta channel's sliding-buffer state.
|
|
158
|
+
// Per-frame inspection only: cross-frame buffering of arbitrary non-JSON
|
|
159
|
+
// frames is out of scope (the delta channel keeps its own buffer).
|
|
160
|
+
async protectText(text) {
|
|
161
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
162
|
+
return { text: text ?? "", blocked: false };
|
|
163
|
+
}
|
|
164
|
+
return transformSegment(text);
|
|
165
|
+
},
|
|
151
166
|
// Protect string leaves of a parsed frame OTHER than the incremental
|
|
152
167
|
// delta text (e.g. tool-call arguments). Returns the mutated object.
|
|
153
168
|
async protectFrameExtras(value) {
|
|
@@ -4,6 +4,37 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
4
4
|
|
|
5
5
|
const ALG = "AES-256-GCM";
|
|
6
6
|
|
|
7
|
+
// Single source of truth for parsing + validating an on-disk local key file.
|
|
8
|
+
// Both the provider's loadKeys() and initLocalKeyFile() (existing-file path)
|
|
9
|
+
// go through here so the 32-byte key invariant is enforced once. Throws a
|
|
10
|
+
// specific error per defect so a corrupted-but-present file is caught at init
|
|
11
|
+
// time instead of failing later during encrypt/decrypt/token/bundle.
|
|
12
|
+
//
|
|
13
|
+
// requireActive: init demands an explicit status:"active" key; the provider
|
|
14
|
+
// keeps its historical fallback to keys[0] when none is marked active.
|
|
15
|
+
async function loadKeyFile(keyFile, { requireActive = false } = {}) {
|
|
16
|
+
const raw = JSON.parse(await readFile(keyFile, "utf8"));
|
|
17
|
+
if (!raw.keys?.length) {
|
|
18
|
+
throw new Error(`No keys found in ${keyFile}`);
|
|
19
|
+
}
|
|
20
|
+
const byKid = new Map();
|
|
21
|
+
for (const entry of raw.keys) {
|
|
22
|
+
const key = Buffer.from(entry.k, "base64url");
|
|
23
|
+
if (key.length !== 32) {
|
|
24
|
+
throw new Error("AES-256-GCM local key must be 32 bytes");
|
|
25
|
+
}
|
|
26
|
+
byKid.set(entry.kid, { kid: entry.kid, key });
|
|
27
|
+
}
|
|
28
|
+
const activeEntry = raw.keys.find((key) => key.status === "active") ?? (requireActive ? null : raw.keys[0]);
|
|
29
|
+
if (!activeEntry) {
|
|
30
|
+
throw new Error("No active key found in local key file");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
active: byKid.get(activeEntry.kid),
|
|
34
|
+
byKid
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
7
38
|
export function createLocalCryptoProvider({ keyFile }) {
|
|
8
39
|
if (!keyFile) {
|
|
9
40
|
throw new Error("Local crypto provider requires keyFile");
|
|
@@ -15,23 +46,7 @@ export function createLocalCryptoProvider({ keyFile }) {
|
|
|
15
46
|
if (cachedKeys) {
|
|
16
47
|
return cachedKeys;
|
|
17
48
|
}
|
|
18
|
-
|
|
19
|
-
if (!raw.keys?.length) {
|
|
20
|
-
throw new Error(`No keys found in ${keyFile}`);
|
|
21
|
-
}
|
|
22
|
-
const byKid = new Map();
|
|
23
|
-
for (const entry of raw.keys) {
|
|
24
|
-
const key = Buffer.from(entry.k, "base64url");
|
|
25
|
-
if (key.length !== 32) {
|
|
26
|
-
throw new Error("AES-256-GCM local key must be 32 bytes");
|
|
27
|
-
}
|
|
28
|
-
byKid.set(entry.kid, { kid: entry.kid, key });
|
|
29
|
-
}
|
|
30
|
-
const activeEntry = raw.keys.find((key) => key.status === "active") ?? raw.keys[0];
|
|
31
|
-
cachedKeys = {
|
|
32
|
-
active: byKid.get(activeEntry.kid),
|
|
33
|
-
byKid
|
|
34
|
-
};
|
|
49
|
+
cachedKeys = await loadKeyFile(keyFile);
|
|
35
50
|
return cachedKeys;
|
|
36
51
|
}
|
|
37
52
|
|
|
@@ -102,15 +117,22 @@ export async function initLocalKeyFile(keyFile, { force = false } = {}) {
|
|
|
102
117
|
await mkdir(dirname(keyFile), { recursive: true });
|
|
103
118
|
|
|
104
119
|
let existing = null;
|
|
120
|
+
let fileExists = true;
|
|
105
121
|
try {
|
|
106
122
|
existing = JSON.parse(await readFile(keyFile, "utf8"));
|
|
107
|
-
if (!force) {
|
|
108
|
-
return { created: false, keyFile };
|
|
109
|
-
}
|
|
110
123
|
} catch (error) {
|
|
111
124
|
if (error.code !== "ENOENT") {
|
|
112
125
|
throw error;
|
|
113
126
|
}
|
|
127
|
+
fileExists = false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (fileExists && !force) {
|
|
131
|
+
// A present key file must be usable, not merely present: validate the
|
|
132
|
+
// active key (base64url, 32 bytes) and every retired key before reporting
|
|
133
|
+
// success, so a corrupted file is rejected here rather than at first use.
|
|
134
|
+
await loadKeyFile(keyFile, { requireActive: true });
|
|
135
|
+
return { created: false, keyFile };
|
|
114
136
|
}
|
|
115
137
|
|
|
116
138
|
// Rotating with --force must not orphan existing envelopes/token vault
|