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.
- package/README.ko.md +46 -11
- package/README.md +46 -11
- package/SECURITY.md +7 -1
- package/docs/README.md +2 -0
- package/docs/current/compliance-mapping.ko.md +53 -0
- package/docs/current/compliance-mapping.md +53 -0
- package/docs/current/config-version.ko.md +30 -0
- package/docs/current/config-version.md +51 -0
- package/docs/current/configuration.ko.md +165 -9
- package/docs/current/configuration.md +165 -9
- package/docs/current/operations-runbook.ko.md +155 -0
- package/docs/current/operations-runbook.md +241 -0
- package/docs/current/release-process.ko.md +5 -1
- package/docs/current/release-process.md +5 -1
- package/docs/current/risk-register-release-gate.ko.md +5 -3
- package/docs/current/risk-register-release-gate.md +13 -3
- package/docs/current/security-whitepaper.ko.md +102 -0
- package/docs/current/security-whitepaper.md +102 -0
- package/docs/current/shared-responsibility.ko.md +2 -2
- package/docs/current/shared-responsibility.md +2 -2
- package/docs/current/threat-model.ko.md +4 -2
- package/docs/current/threat-model.md +4 -2
- package/examples/local-proxy-demo/README.md +51 -0
- package/examples/local-proxy-demo/demo.mjs +144 -0
- package/examples/local-proxy-demo/demo.tape +19 -0
- package/examples/local-proxy-demo/live-demo.mjs +121 -0
- package/examples/local-proxy-demo/live-demo.tape +25 -0
- package/haechi.config.example.json +20 -3
- package/package.json +7 -2
- package/packages/audit/index.mjs +26 -2
- package/packages/cli/bin/haechi.mjs +57 -10
- package/packages/cli/runtime.mjs +402 -10
- package/packages/core/index.mjs +143 -8
- package/packages/filter/index.mjs +975 -12
- package/packages/metrics/index.mjs +181 -0
- package/packages/privacy-profiles/index.mjs +72 -3
- package/packages/protocol-adapters/index.mjs +99 -1
- package/packages/proxy/index.mjs +525 -40
- package/packages/stream-filter/index.mjs +69 -7
package/packages/cli/runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
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") {
|