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.
Files changed (39) hide show
  1. package/README.ko.md +46 -11
  2. package/README.md +46 -11
  3. package/SECURITY.md +7 -1
  4. package/docs/README.md +2 -0
  5. package/docs/current/compliance-mapping.ko.md +53 -0
  6. package/docs/current/compliance-mapping.md +53 -0
  7. package/docs/current/config-version.ko.md +30 -0
  8. package/docs/current/config-version.md +51 -0
  9. package/docs/current/configuration.ko.md +165 -9
  10. package/docs/current/configuration.md +165 -9
  11. package/docs/current/operations-runbook.ko.md +155 -0
  12. package/docs/current/operations-runbook.md +241 -0
  13. package/docs/current/release-process.ko.md +5 -1
  14. package/docs/current/release-process.md +5 -1
  15. package/docs/current/risk-register-release-gate.ko.md +5 -3
  16. package/docs/current/risk-register-release-gate.md +13 -3
  17. package/docs/current/security-whitepaper.ko.md +102 -0
  18. package/docs/current/security-whitepaper.md +102 -0
  19. package/docs/current/shared-responsibility.ko.md +2 -2
  20. package/docs/current/shared-responsibility.md +2 -2
  21. package/docs/current/threat-model.ko.md +4 -2
  22. package/docs/current/threat-model.md +4 -2
  23. package/examples/local-proxy-demo/README.md +51 -0
  24. package/examples/local-proxy-demo/demo.mjs +144 -0
  25. package/examples/local-proxy-demo/demo.tape +19 -0
  26. package/examples/local-proxy-demo/live-demo.mjs +121 -0
  27. package/examples/local-proxy-demo/live-demo.tape +25 -0
  28. package/haechi.config.example.json +20 -3
  29. package/package.json +7 -2
  30. package/packages/audit/index.mjs +26 -2
  31. package/packages/cli/bin/haechi.mjs +57 -10
  32. package/packages/cli/runtime.mjs +402 -10
  33. package/packages/core/index.mjs +143 -8
  34. package/packages/filter/index.mjs +975 -12
  35. package/packages/metrics/index.mjs +181 -0
  36. package/packages/privacy-profiles/index.mjs +72 -3
  37. package/packages/protocol-adapters/index.mjs +99 -1
  38. package/packages/proxy/index.mjs +525 -40
  39. package/packages/stream-filter/index.mjs +69 -7
@@ -0,0 +1,181 @@
1
+ // WS4-A telemetry seam (reliability-hardening-track §WS4 "Telemetry").
2
+ //
3
+ // A minimal, zero-dependency in-memory metrics collector rendering the
4
+ // Prometheus text exposition format. It is an INJECTABLE collaborator
5
+ // (providers.metrics in createRuntime), mirroring auditSink/rateLimiter; an
6
+ // operator who wants a real metrics backend injects their own object exposing
7
+ // the same { increment, observe, render } contract.
8
+ //
9
+ // HARD INVARIANT (the no-plaintext-in-audit invariant, extended to telemetry):
10
+ // every metric name AND every label value is a BOUNDED ENUM — a route id, a
11
+ // policy mode, or a decision class. It is NEVER an identity id/subject, a token,
12
+ // a detected value, or any other unbounded/PII-bearing string. This module does
13
+ // not — and structurally cannot — accept a payload value: callers pass only
14
+ // pre-classified enum labels, and label values are coerced + length-capped here
15
+ // as defence in depth so an accidental high-cardinality value cannot explode
16
+ // the series set or leak content.
17
+
18
+ // Metric catalogue: name -> { type, help }. Counters and one histogram. Keeping
19
+ // the catalogue explicit (rather than letting callers invent metric names)
20
+ // bounds the metric-name dimension to this fixed set.
21
+ const COUNTERS = {
22
+ haechi_requests_total: "Proxy requests by route, mode, and decision class.",
23
+ haechi_blocks_total: "Requests blocked by a policy decision.",
24
+ haechi_auth_denied_total: "Requests denied at authentication.",
25
+ haechi_rate_limited_total: "Requests rejected by the rate limiter.",
26
+ haechi_upstream_timeout_total: "Upstream requests that timed out.",
27
+ haechi_upstream_error_total: "Upstream requests that failed (non-timeout).",
28
+ haechi_response_unprotected_total: "Responses forwarded without protection (size/encoding/parse).",
29
+ haechi_internal_error_total: "Unexpected internal proxy errors.",
30
+ haechi_overloaded_total: "Requests rejected by the max-in-flight backpressure ceiling (503)."
31
+ };
32
+
33
+ const HISTOGRAMS = {
34
+ haechi_request_duration_seconds: "End-to-end proxy request handling duration in seconds."
35
+ };
36
+
37
+ // Default request-duration histogram buckets (seconds). Bounded, fixed set.
38
+ const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
39
+
40
+ // Defence-in-depth label hygiene: a label value must be a short, bounded token.
41
+ // We coerce to string, trim, cap length, and collapse anything outside a safe
42
+ // charset to "_". This guarantees that even a caller mistake cannot place a raw
43
+ // payload value or a long identity string into a series label.
44
+ const MAX_LABEL_LENGTH = 64;
45
+
46
+ function safeLabelValue(value) {
47
+ if (value === undefined || value === null) {
48
+ return "none";
49
+ }
50
+ const text = String(value).slice(0, MAX_LABEL_LENGTH);
51
+ // Allow a conservative identifier charset only (route ids, modes, decisions
52
+ // are all of this shape). Everything else becomes "_".
53
+ return text.replace(/[^A-Za-z0-9_.:/-]/g, "_") || "none";
54
+ }
55
+
56
+ function seriesKey(name, labels) {
57
+ const parts = Object.keys(labels)
58
+ .sort()
59
+ .map((labelName) => `${labelName}="${escapeLabel(labels[labelName])}"`);
60
+ return parts.length > 0 ? `${name}{${parts.join(",")}}` : name;
61
+ }
62
+
63
+ function escapeLabel(value) {
64
+ return String(value).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, "\\\"");
65
+ }
66
+
67
+ export function createMetrics({ buckets = DEFAULT_BUCKETS } = {}) {
68
+ // counterSeries: metricName -> Map(seriesKey -> { labels, value })
69
+ const counterSeries = new Map();
70
+ // histogramSeries: metricName -> Map(seriesKey -> { labels, bucketCounts, sum, count })
71
+ const histogramSeries = new Map();
72
+ const sortedBuckets = [...buckets].sort((a, b) => a - b);
73
+
74
+ function normalizeLabels(labels = {}) {
75
+ const out = {};
76
+ for (const [key, value] of Object.entries(labels)) {
77
+ // Label NAMES are caller-fixed identifiers; coerce defensively anyway.
78
+ const labelName = String(key).replace(/[^A-Za-z0-9_]/g, "_");
79
+ out[labelName] = safeLabelValue(value);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ return {
85
+ // Increment a known counter by `amount` (default 1), labelled by a bounded
86
+ // enum set. An unknown metric name is ignored (fail-soft for telemetry — a
87
+ // metric typo must never break a request path).
88
+ increment(name, labels = {}, amount = 1) {
89
+ if (!(name in COUNTERS)) {
90
+ return;
91
+ }
92
+ const safe = normalizeLabels(labels);
93
+ const key = seriesKey(name, safe);
94
+ let series = counterSeries.get(name);
95
+ if (!series) {
96
+ series = new Map();
97
+ counterSeries.set(name, series);
98
+ }
99
+ const existing = series.get(key);
100
+ if (existing) {
101
+ existing.value += amount;
102
+ } else {
103
+ series.set(key, { labels: safe, value: amount });
104
+ }
105
+ },
106
+
107
+ // Observe a value into a known histogram (request-duration seconds).
108
+ observe(name, value, labels = {}) {
109
+ if (!(name in HISTOGRAMS) || typeof value !== "number" || !Number.isFinite(value)) {
110
+ return;
111
+ }
112
+ const safe = normalizeLabels(labels);
113
+ const key = seriesKey(name, safe);
114
+ let series = histogramSeries.get(name);
115
+ if (!series) {
116
+ series = new Map();
117
+ histogramSeries.set(name, series);
118
+ }
119
+ let entry = series.get(key);
120
+ if (!entry) {
121
+ entry = { labels: safe, bucketCounts: new Array(sortedBuckets.length).fill(0), sum: 0, count: 0 };
122
+ series.set(key, entry);
123
+ }
124
+ entry.sum += value;
125
+ entry.count += 1;
126
+ for (let i = 0; i < sortedBuckets.length; i += 1) {
127
+ if (value <= sortedBuckets[i]) {
128
+ entry.bucketCounts[i] += 1;
129
+ }
130
+ }
131
+ },
132
+
133
+ // Render the full Prometheus text exposition. Every declared counter and
134
+ // histogram emits its HELP/TYPE header even with no observations, so the
135
+ // surface is stable for scrapers.
136
+ render() {
137
+ const lines = [];
138
+
139
+ for (const [name, help] of Object.entries(COUNTERS)) {
140
+ lines.push(`# HELP ${name} ${help}`);
141
+ lines.push(`# TYPE ${name} counter`);
142
+ const series = counterSeries.get(name);
143
+ if (series) {
144
+ for (const { labels, value } of series.values()) {
145
+ lines.push(`${seriesKey(name, labels)} ${value}`);
146
+ }
147
+ }
148
+ }
149
+
150
+ for (const [name, help] of Object.entries(HISTOGRAMS)) {
151
+ lines.push(`# HELP ${name} ${help}`);
152
+ lines.push(`# TYPE ${name} histogram`);
153
+ const series = histogramSeries.get(name);
154
+ if (series) {
155
+ for (const entry of series.values()) {
156
+ // bucketCounts[i] already holds the cumulative count of observations
157
+ // with value <= sortedBuckets[i] (observe() increments every bucket
158
+ // the value falls under), which is exactly the Prometheus le="..."
159
+ // cumulative bucket semantics — emit it directly.
160
+ for (let i = 0; i < sortedBuckets.length; i += 1) {
161
+ const labels = { ...entry.labels, le: String(sortedBuckets[i]) };
162
+ lines.push(`${seriesKey(`${name}_bucket`, labels)} ${entry.bucketCounts[i]}`);
163
+ }
164
+ const infLabels = { ...entry.labels, le: "+Inf" };
165
+ lines.push(`${seriesKey(`${name}_bucket`, infLabels)} ${entry.count}`);
166
+ lines.push(`${seriesKey(`${name}_sum`, entry.labels)} ${entry.sum}`);
167
+ lines.push(`${seriesKey(`${name}_count`, entry.labels)} ${entry.count}`);
168
+ }
169
+ }
170
+ }
171
+
172
+ return `${lines.join("\n")}\n`;
173
+ }
174
+ };
175
+ }
176
+
177
+ // Exported for tests / operators who want to assert the bounded metric surface.
178
+ export const METRIC_NAMES = Object.freeze({
179
+ counters: Object.keys(COUNTERS),
180
+ histograms: Object.keys(HISTOGRAMS)
181
+ });
@@ -12,7 +12,11 @@ const PROFILES = {
12
12
  email: "redact",
13
13
  card: "block",
14
14
  api_key: "block",
15
- secret: "block"
15
+ secret: "block",
16
+ // A Japan My Number leak is as sensitive as a national ID and is a
17
+ // checksummed true-positive — block it in every profile so a non-JP
18
+ // deployment that happens to process JP data is still covered.
19
+ jp_mynumber: "block"
16
20
  }
17
21
  },
18
22
  transfer: {
@@ -31,7 +35,17 @@ const PROFILES = {
31
35
  card: "block",
32
36
  api_key: "block",
33
37
  secret: "block",
34
- kr_rrn: "block"
38
+ kr_rrn: "block",
39
+ // EU national IDs — France NIR, Spain DNI/NIE, UK National Insurance
40
+ // Number, Italy codice fiscale, Germany tax ID, Netherlands BSN — are
41
+ // GDPR special-category-adjacent identifiers; block them.
42
+ fr_nir: "block",
43
+ es_dni: "block",
44
+ uk_nino: "block",
45
+ it_codice_fiscale: "block",
46
+ de_steuer_id: "block",
47
+ nl_bsn: "block",
48
+ jp_mynumber: "block"
35
49
  }
36
50
  },
37
51
  transfer: {
@@ -39,6 +53,37 @@ const PROFILES = {
39
53
  note: "Treat model/tool transfer as processor/subprocessor transfer and document SCC/TIA evidence outside Haechi."
40
54
  }
41
55
  },
56
+ "asia-pdpa": {
57
+ id: "asia-pdpa",
58
+ region: "ASIA",
59
+ regulations: ["Singapore PDPA", "India DPDP Act"],
60
+ policy: {
61
+ actions: {
62
+ // Asia national IDs — Singapore NRIC/FIN and India Aadhaar — are sensitive
63
+ // identifiers under the Singapore PDPA / India DPDP Act; block them. The
64
+ // other checksummed national IDs are also blocked so a mixed-region payload
65
+ // is covered, matching the cross-profile convention.
66
+ sg_nric: "block",
67
+ in_aadhaar: "block",
68
+ jp_mynumber: "block",
69
+ kr_rrn: "block",
70
+ fr_nir: "block",
71
+ es_dni: "block",
72
+ it_codice_fiscale: "block",
73
+ de_steuer_id: "block",
74
+ nl_bsn: "block",
75
+ phone: "mask",
76
+ email: "redact",
77
+ card: "block",
78
+ api_key: "block",
79
+ secret: "block"
80
+ }
81
+ },
82
+ transfer: {
83
+ requiresAssessment: true,
84
+ note: "Document the PDPA/DPDP handling basis, purpose limitation, and cross-border transfer notice before production use."
85
+ }
86
+ },
42
87
  "us-general": {
43
88
  id: "us-general",
44
89
  region: "US",
@@ -49,13 +94,37 @@ const PROFILES = {
49
94
  phone: "mask",
50
95
  card: "block",
51
96
  api_key: "block",
52
- secret: "block"
97
+ secret: "block",
98
+ jp_mynumber: "block"
53
99
  }
54
100
  },
55
101
  transfer: {
56
102
  requiresAssessment: false,
57
103
  note: "Classify sector rules separately before using protected health, payment, or children's data."
58
104
  }
105
+ },
106
+ "jp-appi": {
107
+ id: "jp-appi",
108
+ region: "JP",
109
+ regulations: ["APPI"],
110
+ policy: {
111
+ actions: {
112
+ // My Number (個人番号) is a special-care personal-information identifier
113
+ // under the My Number Act; block it. The EU/KR IDs are also blocked so a
114
+ // mixed-region payload is covered, matching the cross-profile convention.
115
+ jp_mynumber: "block",
116
+ phone: "mask",
117
+ email: "redact",
118
+ card: "block",
119
+ api_key: "block",
120
+ secret: "block",
121
+ kr_rrn: "block"
122
+ }
123
+ },
124
+ transfer: {
125
+ requiresAssessment: true,
126
+ note: "Document the My Number Act handling basis, purpose limitation, and cross-border transfer notice before production use."
127
+ }
59
128
  }
60
129
  };
61
130
 
@@ -8,6 +8,28 @@ const SSE_RESPONSES = { format: "sse", deltaPath: null };
8
8
  const SSE_LLAMA_LEGACY = { format: "sse", deltaPath: ["content"] };
9
9
  const NDJSON_OLLAMA_CHAT = { format: "ndjson", deltaPath: ["message", "content"] };
10
10
  const NDJSON_OLLAMA_GENERATE = { format: "ndjson", deltaPath: ["response"] };
11
+ // Anthropic Messages API streams event-typed SSE frames; the incremental text
12
+ // channel is `delta.text` inside a `content_block_delta` frame. Other frame
13
+ // types (message_start, ping, etc.) don't carry deltaPath, so they get
14
+ // within-frame protection but no cross-frame buffering. The stream-filter
15
+ // preserves each frame's `event:` line on re-serialize. `flushOnType` lists the
16
+ // frame types that END a delta sequence: before one of them the held cross-frame
17
+ // buffer tail is flushed as a valid `content_block_delta`, so the residual lands
18
+ // IN ORDER (before content_block_stop/message_stop) rather than after the stream
19
+ // terminates. `ping` is intentionally absent — a match split across a keepalive
20
+ // must still be caught by the sliding buffer. Legacy /v1/complete streams a
21
+ // `completion` delta (no block framing, so no flushOnType needed).
22
+ const SSE_ANTHROPIC_MESSAGES = {
23
+ format: "sse",
24
+ deltaPath: ["delta", "text"],
25
+ flushOnType: { path: ["type"], values: ["content_block_stop", "message_delta", "message_stop"] }
26
+ };
27
+ const SSE_ANTHROPIC_COMPLETE = { format: "sse", deltaPath: ["completion"] };
28
+ // Google Gemini streams :streamGenerateContent as DATA-ONLY SSE (no `event:`
29
+ // lines, like OpenAI). Each `data:` frame is a FULL GenerateContentResponse;
30
+ // the incremental text channel is just deeper. Because frames are data-only,
31
+ // the held cross-frame tail can flush at end-of-stream — no flushOnType needed.
32
+ const SSE_GEMINI = { format: "sse", deltaPath: ["candidates", 0, "content", "parts", 0, "text"] };
11
33
 
12
34
  const ADAPTERS = {
13
35
  "openai-compatible": {
@@ -50,6 +72,47 @@ const ADAPTERS = {
50
72
  route("/api/embed", "embed"),
51
73
  route("/api/embeddings", "embeddings")
52
74
  ]
75
+ },
76
+ "anthropic": {
77
+ id: "anthropic",
78
+ protocol: "anthropic",
79
+ routes: [
80
+ // Anthropic Messages API. PII can sit in the top-level `system` string/blocks
81
+ // or any `messages[].content` string or content-block text/input — the core
82
+ // tree walk (collectStringEntries) covers every string leaf, so no custom
83
+ // extraction is needed. Streams via content_block_delta `delta.text`.
84
+ route("/v1/messages", "messages", { streaming: SSE_ANTHROPIC_MESSAGES }),
85
+ // count_tokens is a utility, but it carries prompt content, so protect it.
86
+ route("/v1/messages/count_tokens", "count-tokens", { protectRequest: true }),
87
+ // Legacy text completions: `prompt` is a top-level string; streams a `completion` delta.
88
+ route("/v1/complete", "complete", { streaming: SSE_ANTHROPIC_COMPLETE })
89
+ ]
90
+ },
91
+ "gemini": {
92
+ id: "gemini",
93
+ protocol: "gemini",
94
+ routes: [
95
+ // Google Gemini API. Endpoints are MODEL-IN-PATH with a `:method` suffix:
96
+ // POST /v1beta/models/{model}:generateContent (and /v1, and arbitrary
97
+ // model names like gemini-2.0-flash). The route key is therefore the
98
+ // `:method` SUFFIX, not a fixed path — declared via `methodSuffix`, which
99
+ // matchRoute checks only AFTER exact-path matches (so existing adapters
100
+ // are unaffected). PII can sit in systemInstruction.parts[].text and any
101
+ // contents[].parts[].text; the core tree walk (collectStringEntries)
102
+ // covers every string leaf, so no custom extraction is needed.
103
+ suffixRoute("generateContent", "generate-content"),
104
+ // Streaming variant: data-only SSE, full GenerateContentResponse per frame;
105
+ // delta text lives at candidates[0].content.parts[0].text. The :stream*
106
+ // endpoint ALWAYS streams (the intent is in the path, not a body flag), so
107
+ // mark it streamingDefault — there is no `stream:false` body field for
108
+ // Gemini, so isStreamingRequest always classifies it as streaming.
109
+ suffixRoute("streamGenerateContent", "stream-generate-content", { streamingDefault: true, streaming: SSE_GEMINI }),
110
+ // countTokens carries prompt content (contents/systemInstruction), so protect it.
111
+ suffixRoute("countTokens", "count-tokens", { protectRequest: true }),
112
+ // Embedding endpoints: request carries text to embed; protect it.
113
+ suffixRoute("embedContent", "embed", { protectRequest: true }),
114
+ suffixRoute("batchEmbedContents", "batch-embed", { protectRequest: true })
115
+ ]
53
116
  }
54
117
  };
55
118
 
@@ -121,10 +184,45 @@ function route(path, operation, options = {}) {
121
184
  };
122
185
  }
123
186
 
187
+ // A SUFFIX route matches by a `:method` suffix instead of an exact pathname —
188
+ // for model-in-path APIs (Gemini) where the path embeds an arbitrary model name
189
+ // and a version prefix (e.g. /v1beta/models/gemini-2.0-flash:generateContent).
190
+ // `path` stays null (there is no fixed path); `methodSuffix` carries the bare
191
+ // method name and matchRoute matches when the pathname ends with `:${suffix}`.
192
+ // matchRoute tries exact-path routes FIRST, so this never changes existing
193
+ // exact-match behavior for openai/anthropic/ollama/llama-cpp.
194
+ function suffixRoute(methodSuffix, operation, options = {}) {
195
+ return {
196
+ id: operation,
197
+ path: null,
198
+ methodSuffix,
199
+ operation,
200
+ protectRequest: options.protectRequest ?? true,
201
+ protectResponse: options.protectResponse ?? true,
202
+ streamingDefault: options.streamingDefault ?? false,
203
+ streaming: options.streaming ?? null
204
+ };
205
+ }
206
+
124
207
  function pathFromRequestUrl(url) {
125
208
  return new URL(url, "http://haechi.local").pathname;
126
209
  }
127
210
 
128
211
  function matchRoute(routes, pathname) {
129
- return routes.find((candidate) => candidate.path === pathname);
212
+ // 1) EXACT pathname match (unchanged) the only matcher for openai/anthropic/
213
+ // ollama/llama-cpp, so their classification is byte-for-byte identical.
214
+ const exact = routes.find((candidate) => candidate.path === pathname);
215
+ if (exact) {
216
+ return exact;
217
+ }
218
+ // 2) ADDITIVE: `:method`-SUFFIX match for model-in-path APIs (Gemini). A path
219
+ // like /v1beta/models/gemini-2.0-flash:generateContent matches the route
220
+ // whose methodSuffix the pathname ends with (`...:generateContent`). The
221
+ // `:` guard prevents a bare substring (e.g. a model literally named
222
+ // "generateContent") from matching without the method delimiter.
223
+ return routes.find(
224
+ (candidate) =>
225
+ typeof candidate.methodSuffix === "string" &&
226
+ pathname.endsWith(`:${candidate.methodSuffix}`)
227
+ );
130
228
  }