haechi 1.1.2 → 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.
- 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 +147 -7
- package/docs/current/configuration.md +147 -7
- package/docs/current/operations-runbook.ko.md +121 -0
- package/docs/current/operations-runbook.md +204 -0
- package/docs/current/release-process.ko.md +1 -1
- package/docs/current/release-process.md +1 -1
- package/docs/current/risk-register-release-gate.ko.md +3 -2
- package/docs/current/risk-register-release-gate.md +11 -2
- 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 +3 -2
- package/docs/current/threat-model.md +3 -2
- package/haechi.config.example.json +19 -3
- package/package.json +5 -2
- package/packages/audit/index.mjs +26 -2
- package/packages/cli/bin/haechi.mjs +54 -8
- package/packages/cli/runtime.mjs +391 -10
- package/packages/core/index.mjs +143 -8
- package/packages/filter/index.mjs +299 -9
- package/packages/metrics/index.mjs +181 -0
- package/packages/proxy/index.mjs +518 -39
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,12 @@ 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: []
|
|
72
107
|
},
|
|
73
108
|
keys: {
|
|
74
109
|
provider: "local",
|
|
@@ -95,6 +130,19 @@ export function defaultConfig() {
|
|
|
95
130
|
privacy: {
|
|
96
131
|
profile: null
|
|
97
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
|
+
},
|
|
98
146
|
auth: {
|
|
99
147
|
provider: "none",
|
|
100
148
|
store: ".haechi/auth.json",
|
|
@@ -117,7 +165,88 @@ export function defaultConfig() {
|
|
|
117
165
|
|
|
118
166
|
export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
|
|
119
167
|
const raw = JSON.parse(await readFile(configPath, "utf8"));
|
|
120
|
-
|
|
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;
|
|
121
250
|
}
|
|
122
251
|
|
|
123
252
|
export async function writeDefaultConfig(configPath = DEFAULT_CONFIG_PATH, { force = false } = {}) {
|
|
@@ -194,12 +323,30 @@ export function createRuntime(config, providers = {}) {
|
|
|
194
323
|
|
|
195
324
|
const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider, auditSink);
|
|
196
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
|
+
|
|
197
342
|
return {
|
|
198
343
|
config: normalized,
|
|
199
344
|
tokenVault,
|
|
200
345
|
auditSink,
|
|
201
346
|
authProvider,
|
|
202
347
|
policyProfiles,
|
|
348
|
+
rateLimiter,
|
|
349
|
+
metrics,
|
|
203
350
|
protocolAdapter: createProtocolAdapter(normalized.target),
|
|
204
351
|
haechi: createHaechi({
|
|
205
352
|
mode: normalized.mode,
|
|
@@ -210,7 +357,14 @@ export function createRuntime(config, providers = {}) {
|
|
|
210
357
|
auditSink,
|
|
211
358
|
// Bound recursion depth so a deeply-nested payload fails closed (4xx)
|
|
212
359
|
// rather than overflowing the stack (uncaught 500).
|
|
213
|
-
limits: { maxNestingDepth: normalized.limits.maxNestingDepth }
|
|
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
|
+
}
|
|
214
368
|
})
|
|
215
369
|
};
|
|
216
370
|
}
|
|
@@ -219,14 +373,14 @@ export function normalizeConfig(config) {
|
|
|
219
373
|
const merged = {
|
|
220
374
|
...defaultConfig(),
|
|
221
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,
|
|
222
379
|
target: {
|
|
223
380
|
...defaultConfig().target,
|
|
224
381
|
...(config.target ?? {})
|
|
225
382
|
},
|
|
226
|
-
proxy:
|
|
227
|
-
...defaultConfig().proxy,
|
|
228
|
-
...(config.proxy ?? {})
|
|
229
|
-
},
|
|
383
|
+
proxy: normalizeProxy(config.proxy),
|
|
230
384
|
responseProtection: {
|
|
231
385
|
...defaultConfig().responseProtection,
|
|
232
386
|
...(config.responseProtection ?? {})
|
|
@@ -271,6 +425,14 @@ export function normalizeConfig(config) {
|
|
|
271
425
|
...defaultConfig().privacy,
|
|
272
426
|
...(config.privacy ?? {})
|
|
273
427
|
},
|
|
428
|
+
logging: {
|
|
429
|
+
...defaultConfig().logging,
|
|
430
|
+
...(config.logging ?? {})
|
|
431
|
+
},
|
|
432
|
+
metrics: {
|
|
433
|
+
...defaultConfig().metrics,
|
|
434
|
+
...(config.metrics ?? {})
|
|
435
|
+
},
|
|
274
436
|
auth: {
|
|
275
437
|
...defaultConfig().auth,
|
|
276
438
|
...(config.auth ?? {}),
|
|
@@ -296,6 +458,18 @@ export function normalizeConfig(config) {
|
|
|
296
458
|
if (!isValidPort(merged.proxy.port)) {
|
|
297
459
|
throw new Error("proxy.port must be an integer from 0 to 65535");
|
|
298
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
|
+
}
|
|
299
473
|
if (merged.audit.sink !== "jsonl") {
|
|
300
474
|
throw new Error("Current implementation only supports jsonl audit sink");
|
|
301
475
|
}
|
|
@@ -345,6 +519,12 @@ export function normalizeConfig(config) {
|
|
|
345
519
|
if (merged.privacy.profile) {
|
|
346
520
|
getPrivacyProfile(merged.privacy.profile);
|
|
347
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
|
+
}
|
|
348
528
|
if (!["fail-closed", "allow"].includes(merged.responseProtection.failureMode)) {
|
|
349
529
|
throw new Error(`Invalid responseProtection.failureMode: ${merged.responseProtection.failureMode}`);
|
|
350
530
|
}
|
|
@@ -372,7 +552,33 @@ export function normalizeConfig(config) {
|
|
|
372
552
|
if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
|
|
373
553
|
throw new Error("limits.upstreamTimeoutMs must be a positive number");
|
|
374
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
|
+
}
|
|
375
580
|
validatePolicyExtras(merged.policy);
|
|
581
|
+
validateFilters(merged.filters);
|
|
376
582
|
if (!["none", "bearer", "external", "plugin"].includes(merged.auth.provider)) {
|
|
377
583
|
throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
|
|
378
584
|
}
|
|
@@ -394,6 +600,124 @@ export function isValidPort(port) {
|
|
|
394
600
|
return Number.isInteger(port) && port >= 0 && port <= 65535;
|
|
395
601
|
}
|
|
396
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
|
+
|
|
397
721
|
function validatePolicyExtras(policy) {
|
|
398
722
|
if (policy.modelAllowlist !== undefined) {
|
|
399
723
|
assertModelAllowlist(policy.modelAllowlist, "policy.modelAllowlist");
|
|
@@ -667,6 +991,63 @@ function createConfiguredCryptoProvider(config) {
|
|
|
667
991
|
return createLocalCryptoProvider({ keyFile: config.keys.keyFile });
|
|
668
992
|
}
|
|
669
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
|
+
|
|
670
1051
|
function assertProvider(name, provider, methods) {
|
|
671
1052
|
for (const method of methods) {
|
|
672
1053
|
if (typeof provider?.[method] !== "function") {
|