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.
- package/README.ko.md +97 -97
- package/README.md +2 -2
- package/SECURITY.md +19 -11
- package/docs/README.md +2 -0
- package/docs/current/api-stability.ko.md +26 -26
- 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 +242 -102
- package/docs/current/configuration.md +149 -9
- package/docs/current/operations-runbook.ko.md +121 -0
- package/docs/current/operations-runbook.md +204 -0
- package/docs/current/release-process.ko.md +19 -20
- package/docs/current/release-process.md +1 -2
- package/docs/current/reliability-hardening-track.ko.md +77 -0
- package/docs/current/reliability-hardening-track.md +77 -0
- package/docs/current/risk-register-release-gate.ko.md +26 -27
- package/docs/current/risk-register-release-gate.md +27 -20
- package/docs/current/security-whitepaper.ko.md +102 -0
- package/docs/current/security-whitepaper.md +102 -0
- package/docs/current/shared-responsibility.ko.md +33 -24
- package/docs/current/shared-responsibility.md +12 -3
- package/docs/current/threat-model.ko.md +12 -12
- package/docs/current/threat-model.md +3 -3
- package/haechi.config.example.json +19 -3
- package/package.json +6 -2
- package/packages/audit/index.mjs +26 -2
- package/packages/cli/bin/haechi.mjs +54 -8
- package/packages/cli/runtime.mjs +398 -10
- package/packages/core/index.mjs +189 -15
- package/packages/filter/index.mjs +299 -9
- package/packages/metrics/index.mjs +181 -0
- 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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
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,
|
|
@@ -56,7 +77,17 @@ export function defaultConfig() {
|
|
|
56
77
|
},
|
|
57
78
|
limits: {
|
|
58
79
|
maxRequestBytes: 1048576,
|
|
59
|
-
|
|
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
|
-
|
|
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") {
|