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.
Files changed (35) hide show
  1. package/README.ko.md +57 -11
  2. package/README.md +57 -11
  3. package/docs/current/code-review-risk-register-2026-06-16.ko.md +377 -0
  4. package/docs/current/code-review-risk-register-2026-06-16.md +377 -0
  5. package/docs/current/config-version.ko.md +2 -2
  6. package/docs/current/config-version.md +2 -2
  7. package/docs/current/configuration.ko.md +28 -11
  8. package/docs/current/configuration.md +28 -11
  9. package/docs/current/operations-runbook.ko.md +36 -2
  10. package/docs/current/operations-runbook.md +39 -2
  11. package/docs/current/release-process.ko.md +5 -1
  12. package/docs/current/release-process.md +5 -1
  13. package/docs/current/risk-register-release-gate.ko.md +34 -8
  14. package/docs/current/risk-register-release-gate.md +34 -8
  15. package/docs/current/shared-responsibility.ko.md +12 -3
  16. package/docs/current/shared-responsibility.md +12 -3
  17. package/docs/current/threat-model.ko.md +7 -3
  18. package/docs/current/threat-model.md +7 -3
  19. package/examples/local-proxy-demo/README.md +51 -0
  20. package/examples/local-proxy-demo/demo.mjs +144 -0
  21. package/examples/local-proxy-demo/demo.tape +19 -0
  22. package/examples/local-proxy-demo/live-demo.mjs +121 -0
  23. package/examples/local-proxy-demo/live-demo.tape +25 -0
  24. package/haechi.config.example.json +2 -1
  25. package/package.json +3 -1
  26. package/packages/cli/bin/haechi.mjs +95 -5
  27. package/packages/cli/runtime.mjs +61 -1
  28. package/packages/core/index.mjs +15 -0
  29. package/packages/crypto/index.mjs +42 -20
  30. package/packages/filter/index.mjs +679 -6
  31. package/packages/privacy-profiles/index.mjs +72 -3
  32. package/packages/protocol-adapters/index.mjs +99 -1
  33. package/packages/proxy/index.mjs +270 -29
  34. package/packages/ssrf/index.mjs +60 -4
  35. 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 (unknown = fail)
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):
@@ -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") {
@@ -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
- const raw = JSON.parse(await readFile(keyFile, "utf8"));
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