haechi 1.1.1 → 1.2.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 (34) hide show
  1. package/README.ko.md +97 -97
  2. package/README.md +2 -2
  3. package/SECURITY.md +19 -11
  4. package/docs/README.md +2 -0
  5. package/docs/current/api-stability.ko.md +26 -26
  6. package/docs/current/compliance-mapping.ko.md +53 -0
  7. package/docs/current/compliance-mapping.md +53 -0
  8. package/docs/current/config-version.ko.md +30 -0
  9. package/docs/current/config-version.md +51 -0
  10. package/docs/current/configuration.ko.md +242 -102
  11. package/docs/current/configuration.md +149 -9
  12. package/docs/current/operations-runbook.ko.md +121 -0
  13. package/docs/current/operations-runbook.md +204 -0
  14. package/docs/current/release-process.ko.md +19 -20
  15. package/docs/current/release-process.md +1 -2
  16. package/docs/current/reliability-hardening-track.ko.md +77 -0
  17. package/docs/current/reliability-hardening-track.md +77 -0
  18. package/docs/current/risk-register-release-gate.ko.md +26 -27
  19. package/docs/current/risk-register-release-gate.md +27 -20
  20. package/docs/current/security-whitepaper.ko.md +102 -0
  21. package/docs/current/security-whitepaper.md +102 -0
  22. package/docs/current/shared-responsibility.ko.md +33 -24
  23. package/docs/current/shared-responsibility.md +12 -3
  24. package/docs/current/threat-model.ko.md +12 -12
  25. package/docs/current/threat-model.md +3 -3
  26. package/haechi.config.example.json +19 -3
  27. package/package.json +6 -2
  28. package/packages/audit/index.mjs +26 -2
  29. package/packages/cli/bin/haechi.mjs +54 -8
  30. package/packages/cli/runtime.mjs +398 -10
  31. package/packages/core/index.mjs +189 -15
  32. package/packages/filter/index.mjs +299 -9
  33. package/packages/metrics/index.mjs +181 -0
  34. package/packages/proxy/index.mjs +535 -41
@@ -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
- console.log(`Haechi proxy listening on http://${address.host}:${address.port}`);
291
- console.log(`Upstream: ${config.target.upstream}`);
292
- console.log(`Mode: ${effectiveMode}`);
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
- console.error("warning: --allow-remote-bind exposes the proxy beyond loopback. Put Haechi behind explicit network access controls.");
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
- 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.`);
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
- console.error("warning: responseProtection.enabled is false. Upstream responses are forwarded without inspection.");
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. There is no client auth yet — see 'haechi config'."
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]",
@@ -712,6 +756,8 @@ Detection policy
712
756
  policy.defaultAction allow | redact | mask | tokenize | encrypt | block
713
757
  policy.actions per-type overrides; merges may strengthen, not weaken
714
758
  filters.customRules extra regex rules (ReDoS-screened)
759
+ filters.minConfidence [0,1] drop soft detections below this (not hard-block)
760
+ filters.allowlist FP exceptions [value|{value?,path?}] (not hard-block)
715
761
 
716
762
  Tokenization (model sees token, caller sees plaintext)
717
763
  tokenVault.revealPolicy disabled | local-dev (manual reveal gate)
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { readFileSync } from "node:fs";
2
3
  import { dirname } from "node:path";
3
4
  import { createHaechi } from "../core/index.mjs";
4
5
  import { createDefaultFilterEngine } from "../filter/index.mjs";
@@ -8,10 +9,11 @@ import { createJsonlAuditSink } from "../audit/index.mjs";
8
9
  import { createLocalTokenVault } from "../token-vault/index.mjs";
9
10
  import { loadVerifiedPolicyBundleFileSync } from "../policy-bundle/index.mjs";
10
11
  import { createProtocolAdapter } from "../protocol-adapters/index.mjs";
12
+ import { createMetrics } from "../metrics/index.mjs";
11
13
  import { applyPrivacyProfile, getPrivacyProfile } from "../privacy-profiles/index.mjs";
12
14
  import { createBearerAuthProvider } from "../auth/index.mjs";
13
15
  import { createSandboxedAuthProviderSync, createProcessIsolatedAuthProviderSync } from "../plugin/index.mjs";
14
- import { DEFAULT_PROXY_PORT } from "../proxy/index.mjs";
16
+ import { DEFAULT_PROXY_PORT, hasUsableTlsMaterial } from "../proxy/index.mjs";
15
17
 
16
18
  // Capability keys an operator may allowlist for a plugin. Mirrors the plugin
17
19
  // manifest's declared-capability set plus the authProvider-specific
@@ -28,8 +30,17 @@ const KNOWN_PLUGIN_CAPABILITIES = new Set([
28
30
 
29
31
  export const DEFAULT_CONFIG_PATH = "haechi.config.json";
30
32
 
33
+ // Current config schema version. A versioned anchor so a FUTURE breaking schema
34
+ // change has something to compare against. Additive: a config WITHOUT the field
35
+ // is treated as the current version; an unknown/newer value fails closed (a
36
+ // config written by a newer Haechi may use semantics this build does not
37
+ // understand — refuse rather than silently mis-interpret it). See
38
+ // docs/current/config-version.md.
39
+ export const CONFIG_VERSION = 1;
40
+
31
41
  export function defaultConfig() {
32
42
  return {
43
+ configVersion: CONFIG_VERSION,
33
44
  mode: "dry-run",
34
45
  target: {
35
46
  type: "llm-http",
@@ -38,7 +49,17 @@ export function defaultConfig() {
38
49
  },
39
50
  proxy: {
40
51
  host: "127.0.0.1",
41
- port: DEFAULT_PROXY_PORT
52
+ port: DEFAULT_PROXY_PORT,
53
+ // WS6 TLS hardening (additive; defaults preserve 1.1 loopback-plain-http
54
+ // behavior). proxy.tls null = no TLS material; a non-null object is file
55
+ // PATHS loaded at startup into a tlsContext: { keyFile, certFile } or
56
+ // { pfxFile, passphrase? }. A remote (non-loopback) bind REQUIRES either a
57
+ // usable tlsContext OR trustForwardedProto (see assertSafeProxyBind).
58
+ // proxy.trustForwardedProto false = the operator has NOT acknowledged a
59
+ // fronting TLS terminator; true = a trusted reverse proxy terminates TLS in
60
+ // front of Haechi (Haechi then enforces X-Forwarded-Proto: https).
61
+ tls: null,
62
+ trustForwardedProto: false
42
63
  },
43
64
  responseProtection: {
44
65
  enabled: false,
@@ -56,7 +77,17 @@ export function defaultConfig() {
56
77
  },
57
78
  limits: {
58
79
  maxRequestBytes: 1048576,
59
- upstreamTimeoutMs: 120000
80
+ maxNestingDepth: 256,
81
+ upstreamTimeoutMs: 120000,
82
+ // WS4-B resilience (additive; defaults preserve 1.1 behavior).
83
+ // maxInFlight 0 = backpressure disabled (no ceiling). shutdownGraceMs is
84
+ // the graceful-drain grace period before in-flight requests/keep-alive
85
+ // sockets are force-closed on close(). requestTimeoutMs/headersTimeoutMs
86
+ // are null = leave Node's server defaults untouched; set a number to tune.
87
+ maxInFlight: 0,
88
+ shutdownGraceMs: 10000,
89
+ requestTimeoutMs: null,
90
+ headersTimeoutMs: null
60
91
  },
61
92
  policy: {
62
93
  mode: "dry-run",
@@ -67,7 +98,12 @@ export function defaultConfig() {
67
98
  }
68
99
  },
69
100
  filters: {
70
- customRules: []
101
+ customRules: [],
102
+ // WS2c precision dials. minConfidence 0 = current behavior (gate nothing);
103
+ // allowlist [] = no operator FP exceptions. Both additive; neither can
104
+ // suppress a hard-block type (secret/api_key/kr_rrn/card) — see core.
105
+ minConfidence: 0,
106
+ allowlist: []
71
107
  },
72
108
  keys: {
73
109
  provider: "local",
@@ -94,6 +130,19 @@ export function defaultConfig() {
94
130
  privacy: {
95
131
  profile: null
96
132
  },
133
+ // WS4-A operability. Additive; defaults preserve 1.1 behavior.
134
+ // logging.format "text" = the current human-readable lines; "json" = a single
135
+ // JSON line per event (startup/shutdown/error) carrying a correlationId but
136
+ // NEVER a payload/header/token/PII value.
137
+ logging: {
138
+ format: "text"
139
+ },
140
+ // metrics.enabled gates the /__haechi/metrics route. Default true; when false
141
+ // the route returns 404. The metric surface is a bounded enum — no per-identity
142
+ // or per-value label cardinality (see packages/metrics/index.mjs).
143
+ metrics: {
144
+ enabled: true
145
+ },
97
146
  auth: {
98
147
  provider: "none",
99
148
  store: ".haechi/auth.json",
@@ -116,7 +165,88 @@ export function defaultConfig() {
116
165
 
117
166
  export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
118
167
  const raw = JSON.parse(await readFile(configPath, "utf8"));
119
- return normalizeConfig(raw);
168
+ const overlaid = applyEnvOverlay(raw, process.env);
169
+ return normalizeConfig(overlaid);
170
+ }
171
+
172
+ // WS4-B env-var configuration overlay. A FIXED ALLOWLIST of NON-SECRET
173
+ // operational keys may be overridden from the environment for container/12-factor
174
+ // deploys; env WINS over the file for these. Applied AFTER reading the file and
175
+ // BEFORE normalizeConfig, so the overlaid value goes through the same fail-closed
176
+ // validation. An invalid env value (bad port, unknown mode) THROWS — it is never
177
+ // silently ignored — naming the offending variable.
178
+ //
179
+ // SECURITY: secrets/keys/tokens are DELIBERATELY NOT overlayable. There is no
180
+ // HAECHI_* key for keys.*, the auth store/tokens, or any path-to-key — those stay
181
+ // in the config file or are supplied via injected providers. Adding a secret to
182
+ // this allowlist would invite leaking it through a process environment.
183
+ const ENV_OVERLAY = [
184
+ {
185
+ env: "HAECHI_PROXY_PORT",
186
+ apply(config, value) {
187
+ const port = Number(value);
188
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
189
+ throw new Error(`HAECHI_PROXY_PORT must be an integer from 0 to 65535 (got: ${JSON.stringify(value)})`);
190
+ }
191
+ config.proxy = { ...(config.proxy ?? {}), port };
192
+ }
193
+ },
194
+ {
195
+ env: "HAECHI_PROXY_HOST",
196
+ apply(config, value) {
197
+ if (typeof value !== "string" || !value.trim()) {
198
+ throw new Error("HAECHI_PROXY_HOST must be a non-empty string");
199
+ }
200
+ config.proxy = { ...(config.proxy ?? {}), host: value };
201
+ }
202
+ },
203
+ {
204
+ env: "HAECHI_UPSTREAM",
205
+ apply(config, value) {
206
+ if (typeof value !== "string" || !value.trim()) {
207
+ throw new Error("HAECHI_UPSTREAM must be a non-empty URL string");
208
+ }
209
+ try {
210
+ // eslint-disable-next-line no-new
211
+ new URL(value);
212
+ } catch {
213
+ throw new Error(`HAECHI_UPSTREAM must be a valid URL (got: ${JSON.stringify(value)})`);
214
+ }
215
+ config.target = { ...(config.target ?? {}), upstream: value };
216
+ }
217
+ },
218
+ {
219
+ env: "HAECHI_MODE",
220
+ apply(config, value) {
221
+ if (!["dry-run", "report-only", "enforce"].includes(value)) {
222
+ throw new Error(`HAECHI_MODE must be one of dry-run|report-only|enforce (got: ${JSON.stringify(value)})`);
223
+ }
224
+ config.mode = value;
225
+ }
226
+ },
227
+ {
228
+ env: "HAECHI_LOG_FORMAT",
229
+ apply(config, value) {
230
+ if (!["text", "json"].includes(value)) {
231
+ throw new Error(`HAECHI_LOG_FORMAT must be "text" or "json" (got: ${JSON.stringify(value)})`);
232
+ }
233
+ config.logging = { ...(config.logging ?? {}), format: value };
234
+ }
235
+ }
236
+ ];
237
+
238
+ export function applyEnvOverlay(rawConfig, env = process.env) {
239
+ // Clone shallowly so we don't mutate the caller's object; nested objects we
240
+ // touch are themselves shallow-cloned in each apply().
241
+ const config = { ...rawConfig };
242
+ for (const { env: key, apply } of ENV_OVERLAY) {
243
+ const value = env[key];
244
+ if (value === undefined) {
245
+ continue;
246
+ }
247
+ apply(config, value);
248
+ }
249
+ return config;
120
250
  }
121
251
 
122
252
  export async function writeDefaultConfig(configPath = DEFAULT_CONFIG_PATH, { force = false } = {}) {
@@ -193,12 +323,30 @@ export function createRuntime(config, providers = {}) {
193
323
 
194
324
  const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider, auditSink);
195
325
 
326
+ // The proxy's per-identity request rate limiter is an injectable collaborator,
327
+ // mirroring cryptoProvider/auditSink/tokenVault. The default is a per-process
328
+ // in-memory fixed-window counter; a multi-replica operator injects a
329
+ // shared-store implementation. Fail closed at construction if it lacks allow().
330
+ const rateLimiter = providers.rateLimiter ?? createRateLimiter();
331
+ assertProvider("rateLimiter", rateLimiter, ["allow"]);
332
+
333
+ // WS4-A telemetry seam. The metrics collector is an injectable collaborator,
334
+ // mirroring auditSink/rateLimiter. The default is a zero-dep in-memory
335
+ // Prometheus-text collector; a multi-replica operator injects a shared/remote
336
+ // collector exposing the same increment/observe/render contract. The proxy
337
+ // reads runtime.metrics. The metric surface is a bounded enum — never an
338
+ // identity/value label (no-PII-in-telemetry invariant; see metrics module).
339
+ const metrics = providers.metrics ?? createMetrics();
340
+ assertProvider("metrics", metrics, ["increment", "observe", "render"]);
341
+
196
342
  return {
197
343
  config: normalized,
198
344
  tokenVault,
199
345
  auditSink,
200
346
  authProvider,
201
347
  policyProfiles,
348
+ rateLimiter,
349
+ metrics,
202
350
  protocolAdapter: createProtocolAdapter(normalized.target),
203
351
  haechi: createHaechi({
204
352
  mode: normalized.mode,
@@ -206,7 +354,17 @@ export function createRuntime(config, providers = {}) {
206
354
  policyEngine,
207
355
  cryptoProvider,
208
356
  tokenVault,
209
- auditSink
357
+ auditSink,
358
+ // Bound recursion depth so a deeply-nested payload fails closed (4xx)
359
+ // rather than overflowing the stack (uncaught 500).
360
+ limits: { maxNestingDepth: normalized.limits.maxNestingDepth },
361
+ // WS2c precision controls (additive; defaults preserve 1.1 behavior). The
362
+ // detect→decide path drops sub-minConfidence soft detections and suppresses
363
+ // allowlisted soft detections — never a hard-block type (enforced in core).
364
+ precision: {
365
+ minConfidence: normalized.filters.minConfidence,
366
+ allowlist: normalized.filters.allowlist
367
+ }
210
368
  })
211
369
  };
212
370
  }
@@ -215,14 +373,14 @@ export function normalizeConfig(config) {
215
373
  const merged = {
216
374
  ...defaultConfig(),
217
375
  ...config,
376
+ // A config that omits configVersion (e.g. a 1.1 file written before the
377
+ // stamp existed) is treated as the current version, not undefined.
378
+ configVersion: config.configVersion ?? CONFIG_VERSION,
218
379
  target: {
219
380
  ...defaultConfig().target,
220
381
  ...(config.target ?? {})
221
382
  },
222
- proxy: {
223
- ...defaultConfig().proxy,
224
- ...(config.proxy ?? {})
225
- },
383
+ proxy: normalizeProxy(config.proxy),
226
384
  responseProtection: {
227
385
  ...defaultConfig().responseProtection,
228
386
  ...(config.responseProtection ?? {})
@@ -267,6 +425,14 @@ export function normalizeConfig(config) {
267
425
  ...defaultConfig().privacy,
268
426
  ...(config.privacy ?? {})
269
427
  },
428
+ logging: {
429
+ ...defaultConfig().logging,
430
+ ...(config.logging ?? {})
431
+ },
432
+ metrics: {
433
+ ...defaultConfig().metrics,
434
+ ...(config.metrics ?? {})
435
+ },
270
436
  auth: {
271
437
  ...defaultConfig().auth,
272
438
  ...(config.auth ?? {}),
@@ -292,6 +458,18 @@ export function normalizeConfig(config) {
292
458
  if (!isValidPort(merged.proxy.port)) {
293
459
  throw new Error("proxy.port must be an integer from 0 to 65535");
294
460
  }
461
+ if (typeof merged.proxy.trustForwardedProto !== "boolean") {
462
+ throw new Error("proxy.trustForwardedProto must be boolean");
463
+ }
464
+ // proxy.tls has already been resolved by normalizeProxy into either null or a
465
+ // usable tlsContext ({ key, cert } or { pfx, passphrase? }). A non-null value
466
+ // that is not usable TLS material is a fail-closed error (it would otherwise
467
+ // green-light a remote bind that then serves plaintext). normalizeProxy throws
468
+ // first for a malformed shape / unreadable file; this is the belt-and-braces
469
+ // material assertion mirroring the dashboard's tlsContext check.
470
+ if (merged.proxy.tls !== null && !hasUsableTlsMaterial(merged.proxy.tls)) {
471
+ throw new Error("proxy.tls must resolve to usable TLS material ((key && cert) or pfx)");
472
+ }
295
473
  if (merged.audit.sink !== "jsonl") {
296
474
  throw new Error("Current implementation only supports jsonl audit sink");
297
475
  }
@@ -341,6 +519,12 @@ export function normalizeConfig(config) {
341
519
  if (merged.privacy.profile) {
342
520
  getPrivacyProfile(merged.privacy.profile);
343
521
  }
522
+ if (!["text", "json"].includes(merged.logging.format)) {
523
+ throw new Error(`Invalid logging.format: ${merged.logging.format} (expected "text" or "json")`);
524
+ }
525
+ if (typeof merged.metrics.enabled !== "boolean") {
526
+ throw new Error("metrics.enabled must be boolean");
527
+ }
344
528
  if (!["fail-closed", "allow"].includes(merged.responseProtection.failureMode)) {
345
529
  throw new Error(`Invalid responseProtection.failureMode: ${merged.responseProtection.failureMode}`);
346
530
  }
@@ -362,10 +546,39 @@ export function normalizeConfig(config) {
362
546
  if (typeof merged.limits.maxRequestBytes !== "number" || merged.limits.maxRequestBytes < 1) {
363
547
  throw new Error("limits.maxRequestBytes must be a positive number");
364
548
  }
549
+ if (!Number.isInteger(merged.limits.maxNestingDepth) || merged.limits.maxNestingDepth < 1) {
550
+ throw new Error("limits.maxNestingDepth must be a positive integer");
551
+ }
365
552
  if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
366
553
  throw new Error("limits.upstreamTimeoutMs must be a positive number");
367
554
  }
555
+ // WS4-B resilience limits, fail-closed. maxInFlight 0 disables the ceiling.
556
+ if (!Number.isInteger(merged.limits.maxInFlight) || merged.limits.maxInFlight < 0) {
557
+ throw new Error("limits.maxInFlight must be a non-negative integer (0 disables the in-flight ceiling)");
558
+ }
559
+ if (!Number.isInteger(merged.limits.shutdownGraceMs) || merged.limits.shutdownGraceMs < 0) {
560
+ throw new Error("limits.shutdownGraceMs must be a non-negative integer (milliseconds)");
561
+ }
562
+ // requestTimeoutMs/headersTimeoutMs: null leaves Node's server default; a set
563
+ // value must be a non-negative integer (0 disables that timeout, Node semantics).
564
+ for (const field of ["requestTimeoutMs", "headersTimeoutMs"]) {
565
+ const value = merged.limits[field];
566
+ if (value !== null && value !== undefined
567
+ && (!Number.isInteger(value) || value < 0)) {
568
+ throw new Error(`limits.${field} must be null or a non-negative integer (milliseconds; 0 disables the timeout)`);
569
+ }
570
+ }
571
+ // configVersion: a versioned anchor for future schema changes. Fail closed on a
572
+ // newer/unknown version (a config a newer Haechi wrote may use semantics this
573
+ // build does not understand) and on a non-positive-integer value.
574
+ if (!Number.isInteger(merged.configVersion) || merged.configVersion < 1) {
575
+ throw new Error("configVersion must be a positive integer");
576
+ }
577
+ if (merged.configVersion > CONFIG_VERSION) {
578
+ throw new Error(`Unsupported configVersion ${merged.configVersion}: this Haechi build understands configVersion <= ${CONFIG_VERSION}. Upgrade Haechi or lower configVersion (see docs/current/config-version.md).`);
579
+ }
368
580
  validatePolicyExtras(merged.policy);
581
+ validateFilters(merged.filters);
369
582
  if (!["none", "bearer", "external", "plugin"].includes(merged.auth.provider)) {
370
583
  throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
371
584
  }
@@ -387,6 +600,124 @@ export function isValidPort(port) {
387
600
  return Number.isInteger(port) && port >= 0 && port <= 65535;
388
601
  }
389
602
 
603
+ // WS6 proxy normalization. Shallow-merges proxy over the default and resolves
604
+ // proxy.tls from FILE PATHS into a tlsContext loaded at startup. proxy.tls may be:
605
+ // - null (default): no TLS material.
606
+ // - { keyFile, certFile }: PEM key+cert file paths → { key, cert }.
607
+ // - { pfxFile, passphrase? }: a PKCS#12 file path → { pfx, passphrase? }.
608
+ // Fail-closed, enumerated throws: an unknown shape, a missing required field, an
609
+ // unreadable file, or a mix of pfx and key/cert all throw at config time rather
610
+ // than degrading to a plaintext listener later. The loaded buffers ARE the
611
+ // tlsContext handed to https.createServer; node:fs.readFileSync is a builtin
612
+ // (zero runtime dependency).
613
+ function normalizeProxy(proxy) {
614
+ const merged = {
615
+ ...defaultConfig().proxy,
616
+ ...(proxy ?? {})
617
+ };
618
+ merged.tls = resolveProxyTls(merged.tls);
619
+ return merged;
620
+ }
621
+
622
+ function resolveProxyTls(tls) {
623
+ if (tls === undefined || tls === null) {
624
+ return null;
625
+ }
626
+ if (typeof tls !== "object" || Array.isArray(tls)) {
627
+ throw new Error("proxy.tls must be null or an object ({ keyFile, certFile } or { pfxFile, passphrase? })");
628
+ }
629
+ // Already a loaded tlsContext (a hand-built config passing { key, cert } / { pfx }
630
+ // directly, e.g. a test or an embedder) — accept it as-is; the material check in
631
+ // normalizeConfig still gates it. Only resolve the FILE-PATH form below.
632
+ const hasFilePaths = tls.keyFile !== undefined || tls.certFile !== undefined || tls.pfxFile !== undefined;
633
+ const hasInlineMaterial = tls.key !== undefined || tls.cert !== undefined || tls.pfx !== undefined;
634
+ if (hasInlineMaterial && !hasFilePaths) {
635
+ return tls;
636
+ }
637
+
638
+ const usingPfx = tls.pfxFile !== undefined;
639
+ const usingKeyCert = tls.keyFile !== undefined || tls.certFile !== undefined;
640
+ if (usingPfx && usingKeyCert) {
641
+ throw new Error("proxy.tls must use either { keyFile, certFile } or { pfxFile }, not both");
642
+ }
643
+ if (!usingPfx && !usingKeyCert) {
644
+ throw new Error("proxy.tls must set { keyFile, certFile } or { pfxFile }");
645
+ }
646
+
647
+ if (usingPfx) {
648
+ if (typeof tls.pfxFile !== "string" || !tls.pfxFile.trim()) {
649
+ throw new Error("proxy.tls.pfxFile must be a non-empty string path");
650
+ }
651
+ if (tls.passphrase !== undefined && typeof tls.passphrase !== "string") {
652
+ throw new Error("proxy.tls.passphrase must be a string when set");
653
+ }
654
+ const context = { pfx: readTlsFile(tls.pfxFile, "proxy.tls.pfxFile") };
655
+ if (tls.passphrase !== undefined) {
656
+ context.passphrase = tls.passphrase;
657
+ }
658
+ return context;
659
+ }
660
+
661
+ if (typeof tls.keyFile !== "string" || !tls.keyFile.trim()) {
662
+ throw new Error("proxy.tls.keyFile must be a non-empty string path");
663
+ }
664
+ if (typeof tls.certFile !== "string" || !tls.certFile.trim()) {
665
+ throw new Error("proxy.tls.certFile must be a non-empty string path");
666
+ }
667
+ return {
668
+ key: readTlsFile(tls.keyFile, "proxy.tls.keyFile"),
669
+ cert: readTlsFile(tls.certFile, "proxy.tls.certFile")
670
+ };
671
+ }
672
+
673
+ function readTlsFile(path, label) {
674
+ try {
675
+ return readFileSync(path);
676
+ } catch (error) {
677
+ throw new Error(`${label} could not be read: ${error.code ?? error.message}`);
678
+ }
679
+ }
680
+
681
+ // Fail-closed validation of the WS2c precision controls. minConfidence is a
682
+ // number in [0,1]; allowlist is an array of exact-value strings and/or
683
+ // { value?, path? } objects (at least one of value/path must be a non-empty
684
+ // string). A malformed config throws rather than silently degrading.
685
+ function validateFilters(filters) {
686
+ if (filters.minConfidence !== undefined) {
687
+ if (typeof filters.minConfidence !== "number" || Number.isNaN(filters.minConfidence)
688
+ || filters.minConfidence < 0 || filters.minConfidence > 1) {
689
+ throw new Error("filters.minConfidence must be a number in [0, 1]");
690
+ }
691
+ }
692
+ if (filters.allowlist !== undefined) {
693
+ if (!Array.isArray(filters.allowlist)) {
694
+ throw new Error("filters.allowlist must be an array");
695
+ }
696
+ for (const entry of filters.allowlist) {
697
+ if (typeof entry === "string") {
698
+ if (!entry) {
699
+ throw new Error("filters.allowlist string entries must be non-empty");
700
+ }
701
+ continue;
702
+ }
703
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
704
+ throw new Error("filters.allowlist entries must be a string or a { value?, path? } object");
705
+ }
706
+ const hasValue = entry.value !== undefined;
707
+ const hasPath = entry.path !== undefined;
708
+ if (!hasValue && !hasPath) {
709
+ throw new Error("filters.allowlist object entries must set value and/or path");
710
+ }
711
+ if (hasValue && (typeof entry.value !== "string" || !entry.value)) {
712
+ throw new Error("filters.allowlist entry.value must be a non-empty string");
713
+ }
714
+ if (hasPath && (typeof entry.path !== "string" || !entry.path)) {
715
+ throw new Error("filters.allowlist entry.path must be a non-empty string");
716
+ }
717
+ }
718
+ }
719
+ }
720
+
390
721
  function validatePolicyExtras(policy) {
391
722
  if (policy.modelAllowlist !== undefined) {
392
723
  assertModelAllowlist(policy.modelAllowlist, "policy.modelAllowlist");
@@ -660,6 +991,63 @@ function createConfiguredCryptoProvider(config) {
660
991
  return createLocalCryptoProvider({ keyFile: config.keys.keyFile });
661
992
  }
662
993
 
994
+ // Default rate limiter: an in-memory fixed-window counter keyed by identity.
995
+ // Per-process — it resets on restart and is NOT shared across replicas, so a
996
+ // multi-replica operator injects a shared-store implementation via
997
+ // createRuntime(config, { rateLimiter }) (see shared-responsibility.md §4).
998
+ //
999
+ // The window Map is self-bounding via a lazy, amortized sweep — NO timer (a
1000
+ // setInterval would keep the event loop alive and hang `node --test`). On
1001
+ // allow(), when the Map crosses a size threshold we evict a bounded number of
1002
+ // fully-expired entries (now - windowStart >= windowMs). A one-shot identity's
1003
+ // slot therefore does not linger forever once it ages past its window. The
1004
+ // allow(key, limit) -> boolean contract and the fixed-window 429 semantics are
1005
+ // unchanged; only stale bookkeeping is reclaimed.
1006
+ export function createRateLimiter({ windowMs = 60000, sweepThreshold = 1024, sweepBudget = 256 } = {}) {
1007
+ const windows = new Map();
1008
+
1009
+ function sweepExpired(now) {
1010
+ // Bounded amortized eviction: scan at most sweepBudget entries per call (Map
1011
+ // iteration is insertion-ordered, so the oldest keys are visited first) and
1012
+ // drop any whose window has fully elapsed. Amortized O(1) per allow().
1013
+ let scanned = 0;
1014
+ for (const [key, slot] of windows) {
1015
+ if (scanned >= sweepBudget) {
1016
+ break;
1017
+ }
1018
+ scanned += 1;
1019
+ if (now - slot.windowStart >= windowMs) {
1020
+ windows.delete(key);
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ return {
1026
+ allow(key, limit) {
1027
+ const now = Date.now();
1028
+ // Reclaim aged-out one-shot identities before they accumulate unbounded.
1029
+ if (windows.size >= sweepThreshold) {
1030
+ sweepExpired(now);
1031
+ }
1032
+ const slot = windows.get(key);
1033
+ if (!slot || now - slot.windowStart >= windowMs) {
1034
+ windows.set(key, { windowStart: now, count: 1 });
1035
+ return true;
1036
+ }
1037
+ if (slot.count >= limit) {
1038
+ return false;
1039
+ }
1040
+ slot.count += 1;
1041
+ return true;
1042
+ },
1043
+ // Test-only introspection of the live window count. Innocuous: it exposes a
1044
+ // bare integer, never any key/identity value.
1045
+ _size() {
1046
+ return windows.size;
1047
+ }
1048
+ };
1049
+ }
1050
+
663
1051
  function assertProvider(name, provider, methods) {
664
1052
  for (const method of methods) {
665
1053
  if (typeof provider?.[method] !== "function") {