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
@@ -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,
@@ -57,7 +78,16 @@ export function defaultConfig() {
57
78
  limits: {
58
79
  maxRequestBytes: 1048576,
59
80
  maxNestingDepth: 256,
60
- upstreamTimeoutMs: 120000
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
61
91
  },
62
92
  policy: {
63
93
  mode: "dry-run",
@@ -68,7 +98,18 @@ export function defaultConfig() {
68
98
  }
69
99
  },
70
100
  filters: {
71
- 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: [],
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
72
113
  },
73
114
  keys: {
74
115
  provider: "local",
@@ -95,6 +136,19 @@ export function defaultConfig() {
95
136
  privacy: {
96
137
  profile: null
97
138
  },
139
+ // WS4-A operability. Additive; defaults preserve 1.1 behavior.
140
+ // logging.format "text" = the current human-readable lines; "json" = a single
141
+ // JSON line per event (startup/shutdown/error) carrying a correlationId but
142
+ // NEVER a payload/header/token/PII value.
143
+ logging: {
144
+ format: "text"
145
+ },
146
+ // metrics.enabled gates the /__haechi/metrics route. Default true; when false
147
+ // the route returns 404. The metric surface is a bounded enum — no per-identity
148
+ // or per-value label cardinality (see packages/metrics/index.mjs).
149
+ metrics: {
150
+ enabled: true
151
+ },
98
152
  auth: {
99
153
  provider: "none",
100
154
  store: ".haechi/auth.json",
@@ -117,7 +171,88 @@ export function defaultConfig() {
117
171
 
118
172
  export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
119
173
  const raw = JSON.parse(await readFile(configPath, "utf8"));
120
- return normalizeConfig(raw);
174
+ const overlaid = applyEnvOverlay(raw, process.env);
175
+ return normalizeConfig(overlaid);
176
+ }
177
+
178
+ // WS4-B env-var configuration overlay. A FIXED ALLOWLIST of NON-SECRET
179
+ // operational keys may be overridden from the environment for container/12-factor
180
+ // deploys; env WINS over the file for these. Applied AFTER reading the file and
181
+ // BEFORE normalizeConfig, so the overlaid value goes through the same fail-closed
182
+ // validation. An invalid env value (bad port, unknown mode) THROWS — it is never
183
+ // silently ignored — naming the offending variable.
184
+ //
185
+ // SECURITY: secrets/keys/tokens are DELIBERATELY NOT overlayable. There is no
186
+ // HAECHI_* key for keys.*, the auth store/tokens, or any path-to-key — those stay
187
+ // in the config file or are supplied via injected providers. Adding a secret to
188
+ // this allowlist would invite leaking it through a process environment.
189
+ const ENV_OVERLAY = [
190
+ {
191
+ env: "HAECHI_PROXY_PORT",
192
+ apply(config, value) {
193
+ const port = Number(value);
194
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
195
+ throw new Error(`HAECHI_PROXY_PORT must be an integer from 0 to 65535 (got: ${JSON.stringify(value)})`);
196
+ }
197
+ config.proxy = { ...(config.proxy ?? {}), port };
198
+ }
199
+ },
200
+ {
201
+ env: "HAECHI_PROXY_HOST",
202
+ apply(config, value) {
203
+ if (typeof value !== "string" || !value.trim()) {
204
+ throw new Error("HAECHI_PROXY_HOST must be a non-empty string");
205
+ }
206
+ config.proxy = { ...(config.proxy ?? {}), host: value };
207
+ }
208
+ },
209
+ {
210
+ env: "HAECHI_UPSTREAM",
211
+ apply(config, value) {
212
+ if (typeof value !== "string" || !value.trim()) {
213
+ throw new Error("HAECHI_UPSTREAM must be a non-empty URL string");
214
+ }
215
+ try {
216
+ // eslint-disable-next-line no-new
217
+ new URL(value);
218
+ } catch {
219
+ throw new Error(`HAECHI_UPSTREAM must be a valid URL (got: ${JSON.stringify(value)})`);
220
+ }
221
+ config.target = { ...(config.target ?? {}), upstream: value };
222
+ }
223
+ },
224
+ {
225
+ env: "HAECHI_MODE",
226
+ apply(config, value) {
227
+ if (!["dry-run", "report-only", "enforce"].includes(value)) {
228
+ throw new Error(`HAECHI_MODE must be one of dry-run|report-only|enforce (got: ${JSON.stringify(value)})`);
229
+ }
230
+ config.mode = value;
231
+ }
232
+ },
233
+ {
234
+ env: "HAECHI_LOG_FORMAT",
235
+ apply(config, value) {
236
+ if (!["text", "json"].includes(value)) {
237
+ throw new Error(`HAECHI_LOG_FORMAT must be "text" or "json" (got: ${JSON.stringify(value)})`);
238
+ }
239
+ config.logging = { ...(config.logging ?? {}), format: value };
240
+ }
241
+ }
242
+ ];
243
+
244
+ export function applyEnvOverlay(rawConfig, env = process.env) {
245
+ // Clone shallowly so we don't mutate the caller's object; nested objects we
246
+ // touch are themselves shallow-cloned in each apply().
247
+ const config = { ...rawConfig };
248
+ for (const { env: key, apply } of ENV_OVERLAY) {
249
+ const value = env[key];
250
+ if (value === undefined) {
251
+ continue;
252
+ }
253
+ apply(config, value);
254
+ }
255
+ return config;
121
256
  }
122
257
 
123
258
  export async function writeDefaultConfig(configPath = DEFAULT_CONFIG_PATH, { force = false } = {}) {
@@ -194,12 +329,30 @@ export function createRuntime(config, providers = {}) {
194
329
 
195
330
  const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider, auditSink);
196
331
 
332
+ // The proxy's per-identity request rate limiter is an injectable collaborator,
333
+ // mirroring cryptoProvider/auditSink/tokenVault. The default is a per-process
334
+ // in-memory fixed-window counter; a multi-replica operator injects a
335
+ // shared-store implementation. Fail closed at construction if it lacks allow().
336
+ const rateLimiter = providers.rateLimiter ?? createRateLimiter();
337
+ assertProvider("rateLimiter", rateLimiter, ["allow"]);
338
+
339
+ // WS4-A telemetry seam. The metrics collector is an injectable collaborator,
340
+ // mirroring auditSink/rateLimiter. The default is a zero-dep in-memory
341
+ // Prometheus-text collector; a multi-replica operator injects a shared/remote
342
+ // collector exposing the same increment/observe/render contract. The proxy
343
+ // reads runtime.metrics. The metric surface is a bounded enum — never an
344
+ // identity/value label (no-PII-in-telemetry invariant; see metrics module).
345
+ const metrics = providers.metrics ?? createMetrics();
346
+ assertProvider("metrics", metrics, ["increment", "observe", "render"]);
347
+
197
348
  return {
198
349
  config: normalized,
199
350
  tokenVault,
200
351
  auditSink,
201
352
  authProvider,
202
353
  policyProfiles,
354
+ rateLimiter,
355
+ metrics,
203
356
  protocolAdapter: createProtocolAdapter(normalized.target),
204
357
  haechi: createHaechi({
205
358
  mode: normalized.mode,
@@ -210,7 +363,14 @@ export function createRuntime(config, providers = {}) {
210
363
  auditSink,
211
364
  // Bound recursion depth so a deeply-nested payload fails closed (4xx)
212
365
  // rather than overflowing the stack (uncaught 500).
213
- limits: { maxNestingDepth: normalized.limits.maxNestingDepth }
366
+ limits: { maxNestingDepth: normalized.limits.maxNestingDepth },
367
+ // WS2c precision controls (additive; defaults preserve 1.1 behavior). The
368
+ // detect→decide path drops sub-minConfidence soft detections and suppresses
369
+ // allowlisted soft detections — never a hard-block type (enforced in core).
370
+ precision: {
371
+ minConfidence: normalized.filters.minConfidence,
372
+ allowlist: normalized.filters.allowlist
373
+ }
214
374
  })
215
375
  };
216
376
  }
@@ -219,14 +379,14 @@ export function normalizeConfig(config) {
219
379
  const merged = {
220
380
  ...defaultConfig(),
221
381
  ...config,
382
+ // A config that omits configVersion (e.g. a 1.1 file written before the
383
+ // stamp existed) is treated as the current version, not undefined.
384
+ configVersion: config.configVersion ?? CONFIG_VERSION,
222
385
  target: {
223
386
  ...defaultConfig().target,
224
387
  ...(config.target ?? {})
225
388
  },
226
- proxy: {
227
- ...defaultConfig().proxy,
228
- ...(config.proxy ?? {})
229
- },
389
+ proxy: normalizeProxy(config.proxy),
230
390
  responseProtection: {
231
391
  ...defaultConfig().responseProtection,
232
392
  ...(config.responseProtection ?? {})
@@ -271,6 +431,14 @@ export function normalizeConfig(config) {
271
431
  ...defaultConfig().privacy,
272
432
  ...(config.privacy ?? {})
273
433
  },
434
+ logging: {
435
+ ...defaultConfig().logging,
436
+ ...(config.logging ?? {})
437
+ },
438
+ metrics: {
439
+ ...defaultConfig().metrics,
440
+ ...(config.metrics ?? {})
441
+ },
274
442
  auth: {
275
443
  ...defaultConfig().auth,
276
444
  ...(config.auth ?? {}),
@@ -296,6 +464,18 @@ export function normalizeConfig(config) {
296
464
  if (!isValidPort(merged.proxy.port)) {
297
465
  throw new Error("proxy.port must be an integer from 0 to 65535");
298
466
  }
467
+ if (typeof merged.proxy.trustForwardedProto !== "boolean") {
468
+ throw new Error("proxy.trustForwardedProto must be boolean");
469
+ }
470
+ // proxy.tls has already been resolved by normalizeProxy into either null or a
471
+ // usable tlsContext ({ key, cert } or { pfx, passphrase? }). A non-null value
472
+ // that is not usable TLS material is a fail-closed error (it would otherwise
473
+ // green-light a remote bind that then serves plaintext). normalizeProxy throws
474
+ // first for a malformed shape / unreadable file; this is the belt-and-braces
475
+ // material assertion mirroring the dashboard's tlsContext check.
476
+ if (merged.proxy.tls !== null && !hasUsableTlsMaterial(merged.proxy.tls)) {
477
+ throw new Error("proxy.tls must resolve to usable TLS material ((key && cert) or pfx)");
478
+ }
299
479
  if (merged.audit.sink !== "jsonl") {
300
480
  throw new Error("Current implementation only supports jsonl audit sink");
301
481
  }
@@ -345,6 +525,12 @@ export function normalizeConfig(config) {
345
525
  if (merged.privacy.profile) {
346
526
  getPrivacyProfile(merged.privacy.profile);
347
527
  }
528
+ if (!["text", "json"].includes(merged.logging.format)) {
529
+ throw new Error(`Invalid logging.format: ${merged.logging.format} (expected "text" or "json")`);
530
+ }
531
+ if (typeof merged.metrics.enabled !== "boolean") {
532
+ throw new Error("metrics.enabled must be boolean");
533
+ }
348
534
  if (!["fail-closed", "allow"].includes(merged.responseProtection.failureMode)) {
349
535
  throw new Error(`Invalid responseProtection.failureMode: ${merged.responseProtection.failureMode}`);
350
536
  }
@@ -372,7 +558,33 @@ export function normalizeConfig(config) {
372
558
  if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
373
559
  throw new Error("limits.upstreamTimeoutMs must be a positive number");
374
560
  }
561
+ // WS4-B resilience limits, fail-closed. maxInFlight 0 disables the ceiling.
562
+ if (!Number.isInteger(merged.limits.maxInFlight) || merged.limits.maxInFlight < 0) {
563
+ throw new Error("limits.maxInFlight must be a non-negative integer (0 disables the in-flight ceiling)");
564
+ }
565
+ if (!Number.isInteger(merged.limits.shutdownGraceMs) || merged.limits.shutdownGraceMs < 0) {
566
+ throw new Error("limits.shutdownGraceMs must be a non-negative integer (milliseconds)");
567
+ }
568
+ // requestTimeoutMs/headersTimeoutMs: null leaves Node's server default; a set
569
+ // value must be a non-negative integer (0 disables that timeout, Node semantics).
570
+ for (const field of ["requestTimeoutMs", "headersTimeoutMs"]) {
571
+ const value = merged.limits[field];
572
+ if (value !== null && value !== undefined
573
+ && (!Number.isInteger(value) || value < 0)) {
574
+ throw new Error(`limits.${field} must be null or a non-negative integer (milliseconds; 0 disables the timeout)`);
575
+ }
576
+ }
577
+ // configVersion: a versioned anchor for future schema changes. Fail closed on a
578
+ // newer/unknown version (a config a newer Haechi wrote may use semantics this
579
+ // build does not understand) and on a non-positive-integer value.
580
+ if (!Number.isInteger(merged.configVersion) || merged.configVersion < 1) {
581
+ throw new Error("configVersion must be a positive integer");
582
+ }
583
+ if (merged.configVersion > CONFIG_VERSION) {
584
+ 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).`);
585
+ }
375
586
  validatePolicyExtras(merged.policy);
587
+ validateFilters(merged.filters);
376
588
  if (!["none", "bearer", "external", "plugin"].includes(merged.auth.provider)) {
377
589
  throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
378
590
  }
@@ -394,6 +606,129 @@ export function isValidPort(port) {
394
606
  return Number.isInteger(port) && port >= 0 && port <= 65535;
395
607
  }
396
608
 
609
+ // WS6 proxy normalization. Shallow-merges proxy over the default and resolves
610
+ // proxy.tls from FILE PATHS into a tlsContext loaded at startup. proxy.tls may be:
611
+ // - null (default): no TLS material.
612
+ // - { keyFile, certFile }: PEM key+cert file paths → { key, cert }.
613
+ // - { pfxFile, passphrase? }: a PKCS#12 file path → { pfx, passphrase? }.
614
+ // Fail-closed, enumerated throws: an unknown shape, a missing required field, an
615
+ // unreadable file, or a mix of pfx and key/cert all throw at config time rather
616
+ // than degrading to a plaintext listener later. The loaded buffers ARE the
617
+ // tlsContext handed to https.createServer; node:fs.readFileSync is a builtin
618
+ // (zero runtime dependency).
619
+ function normalizeProxy(proxy) {
620
+ const merged = {
621
+ ...defaultConfig().proxy,
622
+ ...(proxy ?? {})
623
+ };
624
+ merged.tls = resolveProxyTls(merged.tls);
625
+ return merged;
626
+ }
627
+
628
+ function resolveProxyTls(tls) {
629
+ if (tls === undefined || tls === null) {
630
+ return null;
631
+ }
632
+ if (typeof tls !== "object" || Array.isArray(tls)) {
633
+ throw new Error("proxy.tls must be null or an object ({ keyFile, certFile } or { pfxFile, passphrase? })");
634
+ }
635
+ // Already a loaded tlsContext (a hand-built config passing { key, cert } / { pfx }
636
+ // directly, e.g. a test or an embedder) — accept it as-is; the material check in
637
+ // normalizeConfig still gates it. Only resolve the FILE-PATH form below.
638
+ const hasFilePaths = tls.keyFile !== undefined || tls.certFile !== undefined || tls.pfxFile !== undefined;
639
+ const hasInlineMaterial = tls.key !== undefined || tls.cert !== undefined || tls.pfx !== undefined;
640
+ if (hasInlineMaterial && !hasFilePaths) {
641
+ return tls;
642
+ }
643
+
644
+ const usingPfx = tls.pfxFile !== undefined;
645
+ const usingKeyCert = tls.keyFile !== undefined || tls.certFile !== undefined;
646
+ if (usingPfx && usingKeyCert) {
647
+ throw new Error("proxy.tls must use either { keyFile, certFile } or { pfxFile }, not both");
648
+ }
649
+ if (!usingPfx && !usingKeyCert) {
650
+ throw new Error("proxy.tls must set { keyFile, certFile } or { pfxFile }");
651
+ }
652
+
653
+ if (usingPfx) {
654
+ if (typeof tls.pfxFile !== "string" || !tls.pfxFile.trim()) {
655
+ throw new Error("proxy.tls.pfxFile must be a non-empty string path");
656
+ }
657
+ if (tls.passphrase !== undefined && typeof tls.passphrase !== "string") {
658
+ throw new Error("proxy.tls.passphrase must be a string when set");
659
+ }
660
+ const context = { pfx: readTlsFile(tls.pfxFile, "proxy.tls.pfxFile") };
661
+ if (tls.passphrase !== undefined) {
662
+ context.passphrase = tls.passphrase;
663
+ }
664
+ return context;
665
+ }
666
+
667
+ if (typeof tls.keyFile !== "string" || !tls.keyFile.trim()) {
668
+ throw new Error("proxy.tls.keyFile must be a non-empty string path");
669
+ }
670
+ if (typeof tls.certFile !== "string" || !tls.certFile.trim()) {
671
+ throw new Error("proxy.tls.certFile must be a non-empty string path");
672
+ }
673
+ return {
674
+ key: readTlsFile(tls.keyFile, "proxy.tls.keyFile"),
675
+ cert: readTlsFile(tls.certFile, "proxy.tls.certFile")
676
+ };
677
+ }
678
+
679
+ function readTlsFile(path, label) {
680
+ try {
681
+ return readFileSync(path);
682
+ } catch (error) {
683
+ throw new Error(`${label} could not be read: ${error.code ?? error.message}`);
684
+ }
685
+ }
686
+
687
+ // Fail-closed validation of the WS2c precision controls. minConfidence is a
688
+ // number in [0,1]; allowlist is an array of exact-value strings and/or
689
+ // { value?, path? } objects (at least one of value/path must be a non-empty
690
+ // string). A malformed config throws rather than silently degrading.
691
+ function validateFilters(filters) {
692
+ if (filters.minConfidence !== undefined) {
693
+ if (typeof filters.minConfidence !== "number" || Number.isNaN(filters.minConfidence)
694
+ || filters.minConfidence < 0 || filters.minConfidence > 1) {
695
+ throw new Error("filters.minConfidence must be a number in [0, 1]");
696
+ }
697
+ }
698
+ if (filters.allowlist !== undefined) {
699
+ if (!Array.isArray(filters.allowlist)) {
700
+ throw new Error("filters.allowlist must be an array");
701
+ }
702
+ for (const entry of filters.allowlist) {
703
+ if (typeof entry === "string") {
704
+ if (!entry) {
705
+ throw new Error("filters.allowlist string entries must be non-empty");
706
+ }
707
+ continue;
708
+ }
709
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
710
+ throw new Error("filters.allowlist entries must be a string or a { value?, path? } object");
711
+ }
712
+ const hasValue = entry.value !== undefined;
713
+ const hasPath = entry.path !== undefined;
714
+ if (!hasValue && !hasPath) {
715
+ throw new Error("filters.allowlist object entries must set value and/or path");
716
+ }
717
+ if (hasValue && (typeof entry.value !== "string" || !entry.value)) {
718
+ throw new Error("filters.allowlist entry.value must be a non-empty string");
719
+ }
720
+ if (hasPath && (typeof entry.path !== "string" || !entry.path)) {
721
+ throw new Error("filters.allowlist entry.path must be a non-empty string");
722
+ }
723
+ }
724
+ }
725
+ // WS2d residual — opt-in base64/percent decode-and-rescan. Strict boolean,
726
+ // fail-closed: a non-boolean throws rather than silently coercing.
727
+ if (filters.decodeAndRescan !== undefined && typeof filters.decodeAndRescan !== "boolean") {
728
+ throw new Error("filters.decodeAndRescan must be a boolean");
729
+ }
730
+ }
731
+
397
732
  function validatePolicyExtras(policy) {
398
733
  if (policy.modelAllowlist !== undefined) {
399
734
  assertModelAllowlist(policy.modelAllowlist, "policy.modelAllowlist");
@@ -667,6 +1002,63 @@ function createConfiguredCryptoProvider(config) {
667
1002
  return createLocalCryptoProvider({ keyFile: config.keys.keyFile });
668
1003
  }
669
1004
 
1005
+ // Default rate limiter: an in-memory fixed-window counter keyed by identity.
1006
+ // Per-process — it resets on restart and is NOT shared across replicas, so a
1007
+ // multi-replica operator injects a shared-store implementation via
1008
+ // createRuntime(config, { rateLimiter }) (see shared-responsibility.md §4).
1009
+ //
1010
+ // The window Map is self-bounding via a lazy, amortized sweep — NO timer (a
1011
+ // setInterval would keep the event loop alive and hang `node --test`). On
1012
+ // allow(), when the Map crosses a size threshold we evict a bounded number of
1013
+ // fully-expired entries (now - windowStart >= windowMs). A one-shot identity's
1014
+ // slot therefore does not linger forever once it ages past its window. The
1015
+ // allow(key, limit) -> boolean contract and the fixed-window 429 semantics are
1016
+ // unchanged; only stale bookkeeping is reclaimed.
1017
+ export function createRateLimiter({ windowMs = 60000, sweepThreshold = 1024, sweepBudget = 256 } = {}) {
1018
+ const windows = new Map();
1019
+
1020
+ function sweepExpired(now) {
1021
+ // Bounded amortized eviction: scan at most sweepBudget entries per call (Map
1022
+ // iteration is insertion-ordered, so the oldest keys are visited first) and
1023
+ // drop any whose window has fully elapsed. Amortized O(1) per allow().
1024
+ let scanned = 0;
1025
+ for (const [key, slot] of windows) {
1026
+ if (scanned >= sweepBudget) {
1027
+ break;
1028
+ }
1029
+ scanned += 1;
1030
+ if (now - slot.windowStart >= windowMs) {
1031
+ windows.delete(key);
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ return {
1037
+ allow(key, limit) {
1038
+ const now = Date.now();
1039
+ // Reclaim aged-out one-shot identities before they accumulate unbounded.
1040
+ if (windows.size >= sweepThreshold) {
1041
+ sweepExpired(now);
1042
+ }
1043
+ const slot = windows.get(key);
1044
+ if (!slot || now - slot.windowStart >= windowMs) {
1045
+ windows.set(key, { windowStart: now, count: 1 });
1046
+ return true;
1047
+ }
1048
+ if (slot.count >= limit) {
1049
+ return false;
1050
+ }
1051
+ slot.count += 1;
1052
+ return true;
1053
+ },
1054
+ // Test-only introspection of the live window count. Innocuous: it exposes a
1055
+ // bare integer, never any key/identity value.
1056
+ _size() {
1057
+ return windows.size;
1058
+ }
1059
+ };
1060
+ }
1061
+
670
1062
  function assertProvider(name, provider, methods) {
671
1063
  for (const method of methods) {
672
1064
  if (typeof provider?.[method] !== "function") {