haechi 1.3.0 → 1.3.1

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.
@@ -0,0 +1,377 @@
1
+ # 2026-06-16 Full Code Review Risk Register
2
+
3
+ Status: remediation complete and shipped in `haechi@1.3.1` (all 13 findings Resolved; G9 Pass, 2026-06-16)
4
+ Scope: `main` at `a47a6a79c380db412b6a464a2798b7df61f3b68d`
5
+ Review date: 2026-06-16
6
+ Source: full repository code review with focused security, protocol, packaging, and regression-test passes
7
+
8
+ This register captures risks discovered after the 0.3.2 and 1.3.x hardening work. It is intentionally separate from the historical release-gate register so future remediation can update each item without rewriting the earlier release record.
9
+
10
+ ## Release Decision
11
+
12
+ Until the P0/P1 items below are fixed or explicitly accepted with a documented owner decision, new release tags and npm publishes should be blocked.
13
+
14
+ Public source availability can continue because the repository is already public and these findings are tracked openly. The client-credential forwarding risk (P0-CR-001) is now Resolved — the proxy applies a default-drop upstream header allowlist and never forwards the gateway `Authorization`/`Cookie`/`Proxy-Authorization` to the model upstream. The hex IPv4-mapped IPv6 SSRF gap (P1-CR-002) and its vault test gap (P2-CR-012) are also now Resolved — every `isBlockedAddress` copy normalizes an IPv4-mapped IPv6 address to its embedded IPv4 before the private-range check. The streaming-inspection bypass (P1-CR-005) and the SSE multi-line `data:` correctness gap (P2-CR-013) are also now Resolved — a parse-failed non-JSON CONTENT frame is inspected as text and multi-line `data:` lines are joined with the spec-required newline. The final six P2s (P2-CR-006 mcp-wrap stderr, P2-CR-007 init key-file validation, P2-CR-008 satellite `manifest.bin` check, P2-CR-009 auth-throw test, P2-CR-010 process-sandbox quota tests, P2-CR-011 audit middle-tamper tests) are now Resolved as well. **All 13 findings are Resolved and shipped in `haechi@1.3.1`** (2026-06-16, attested OIDC publish; core bumped 1.3.0 → 1.3.1, a remediation-only patch). The **G9** release-block gate is **Pass**. Operators should upgrade from `1.3.0` to `1.3.1` to pick up the fixes.
15
+
16
+ ## Severity Policy
17
+
18
+ - `P0`: direct credential/data leak across a trust boundary, or a bypass that defeats the core security promise.
19
+ - `P1`: SSRF, protection bypass, denial-of-service, or protocol behavior that can break protected deployments.
20
+ - `P2`: operational, packaging, correctness, or regression-test gaps that should be resolved before broad adoption.
21
+
22
+ ## Summary
23
+
24
+ | ID | Severity | Area | Risk | Status | Release impact |
25
+ | --- | --- | --- | --- | --- | --- |
26
+ | P0-CR-001 | P0 | Proxy headers | Client `Authorization`, `Cookie`, proxy-auth, and similar ambient credentials can be forwarded to the model upstream. | Resolved | Was blocking release |
27
+ | P1-CR-002 | P1 | SSRF guard | Hex-form IPv4-mapped IPv6 addresses such as `::ffff:7f00:1` are not classified as private loopback. | Resolved | Was blocking release |
28
+ | P1-CR-003 | P1 | Proxy responses | Auto-decompressed upstream bodies can be returned with original compressed `content-encoding` / `content-length` headers. | Resolved | Was blocking release |
29
+ | P1-CR-004 | P1 | Streaming | `streaming.requestMode: "pass-through"` buffers the full upstream body and has no response-size cap. | Resolved | Was blocking release |
30
+ | P1-CR-005 | P1 | Streaming inspection | Non-JSON SSE/NDJSON frames are passed raw, so plain-text PII can bypass protection. | Resolved | Was blocking release |
31
+ | P2-CR-006 | P2 | MCP wrap | Child process `stderr` is inherited and unfiltered. | Resolved | Was a hardening gap |
32
+ | P2-CR-007 | P2 | Key custody | `initLocalKeyFile()` reports success for existing files without validating key-file shape. | Resolved | Was a hardening gap |
33
+ | P2-CR-008 | P2 | Satellite packaging | Satellite packaging checks do not validate `manifest.bin` targets. | Resolved | Was a hardening gap |
34
+ | P2-CR-009 | P2 | Auth tests | `authProvider.authenticate()` throw path lacks a focused regression test. | Resolved | Was a test gap |
35
+ | P2-CR-010 | P2 | Plugin sandbox tests | Process-isolated quota and oversize branches lack parity with worker sandbox tests. | Resolved | Was a test gap |
36
+ | P2-CR-011 | P2 | Audit tests | Middle-record audit-chain tamper paths lack focused regression coverage. | Resolved | Was a test gap |
37
+ | P2-CR-012 | P2 | Vault tests | KMS vault IPv6 loopback carve-out has only IPv4 coverage. | Resolved | Was a test gap |
38
+ | P2-CR-013 | P2 | SSE correctness | Multi-line SSE `data:` fields are joined without the spec-required newline. | Resolved | Was a correctness gap |
39
+
40
+ ## Detailed Findings
41
+
42
+ ### P0-CR-001: Proxy Forwards Client Credentials Upstream
43
+
44
+ Status: Resolved (2026-06-16, toward 1.3.1)
45
+ Affected code: `packages/proxy/index.mjs` `forward()` and `filteredHeaders()`
46
+ Evidence:
47
+
48
+ - `forward()` sends `filteredHeaders(request.headers)` into upstream `fetch()`.
49
+ - `filteredHeaders()` currently drops only `host` and `content-length`, then rewrites JSON `content-type`.
50
+ - A local upstream repro received the same Haechi bearer token that the client used for gateway authentication.
51
+
52
+ Impact:
53
+
54
+ Client credentials cross from the local gateway trust boundary into the model provider boundary. This can leak Haechi bearer tokens, cookies, `Proxy-Authorization`, browser-origin headers, or other ambient client secrets. It also makes it hard to reason about future auth modules because client identity and upstream provider credentials are not separated.
55
+
56
+ Required remediation:
57
+
58
+ - Replace the current header pass-through with an explicit upstream header allowlist.
59
+ - Separate gateway-client auth from upstream-provider auth.
60
+ - Preserve provider-required headers only through explicit adapter or config rules, for example Anthropic/Gemini API-key headers when intentionally configured as upstream credentials.
61
+ - Drop hop-by-hop, cookie, proxy-auth, and gateway-client authorization headers by default.
62
+
63
+ Minimum verification:
64
+
65
+ - Regression test proving gateway bearer tokens are not visible to a local upstream.
66
+ - Regression test for intentionally configured upstream credentials.
67
+ - README and release-process wording updated so users do not assume client auth is forwarded safely.
68
+
69
+ Resolution evidence:
70
+
71
+ - `filteredHeaders()` (`packages/proxy/index.mjs`) is now a DEFAULT-DROP allowlist: a `FORWARD_HEADER_ALLOWLIST` (provider/adapter headers `x-api-key`, `anthropic-version`, `anthropic-beta`, `x-goog-api-key`, `openai-organization`, `openai-beta`, `accept`, `accept-language`, `user-agent`; `content-type` rewritten to `application/json`), an always-drop `FORWARD_HEADER_DENYLIST` (`host`, `content-length`, `cookie`, `set-cookie`, `proxy-authorization`, and hop-by-hop `connection`/`keep-alive`/`te`/`trailer`/`transfer-encoding`/`upgrade`), and a conditional `authorization` rule.
72
+ - `createHaechiProxy` derives a `forwardPolicy` once and threads it through every `forward()` callsite (protected path, streaming pass-through, inspected stream). `gatewayConsumedAuthorization` is `auth.provider !== "none"`: when the gateway authenticated the client the request `Authorization` (the gateway credential) is DROPPED; with `auth.provider: none` it is FORWARDED (the upstream provider key, OpenAI-compatible pass-through pattern).
73
+ - Additive config escape hatch `target.forwardHeaders` (array of lowercase header names), validated fail-closed in `normalizeConfig` (`validateForwardHeaders`): non-array, non-lowercase, or always-dropped credential/hop-by-hop names throw at load; it can only widen, never re-enable a dropped header.
74
+ - Regression tests in `tests/proxy-header-allowlist.test.mjs`: a gateway bearer token (`auth.provider: bearer`) is NOT in the headers a stub upstream receives while provider headers ARE; cookie/proxy-authorization/hop-by-hop and unlisted headers are dropped; `auth.provider: none` forwards the client `Authorization`; `target.forwardHeaders` widens additively; the config validator is fail-closed. The existing `tests/proxy-auth.test.mjs` 401/profile/rate suites stay green.
75
+ - Docs: README.md(+ko) "Gateway auth vs upstream auth (header forwarding)" + config-table row; `threat-model.md`(+ko) "Gateway credential forwarded upstream" control row; `shared-responsibility.md`(+ko) §5 + matrix row; `configuration.md`(+ko) `target.forwardHeaders` + the fail-closed throws list.
76
+
77
+ Release decision: blocks any new release or npm publish until fixed or formally accepted. Resolved.
78
+
79
+ ### P1-CR-002: SSRF Guard Misses Hex IPv4-Mapped IPv6
80
+
81
+ Status: Resolved (2026-06-16, toward 1.3.1)
82
+ Affected code: `packages/ssrf/index.mjs`, `satellites/auth-jwt/index.mjs` (and `satellites/crypto-kms/vault.mjs`, which had a related over-block)
83
+ Evidence:
84
+
85
+ Manual classification check (after the fix — the formerly-misclassified rows are now correct):
86
+
87
+ | Input | Result | Expected |
88
+ | --- | --- | --- |
89
+ | `::ffff:127.0.0.1` | Private | Private |
90
+ | `::ffff:7f00:1` | Private | Private |
91
+ | `[::ffff:7f00:1]` | Private | Private |
92
+ | `::ffff:10.0.0.1` | Private | Private |
93
+ | `::ffff:a00:1` | Private | Private |
94
+ | `::ffff:8.8.8.8` / `::ffff:808:808` | Public | Public (not over-blocked) |
95
+
96
+ Impact (was):
97
+
98
+ Guarded fetch paths could misclassify private loopback or RFC1918 IPv4 targets when they were represented as hexadecimal IPv4-mapped IPv6. This affected core guarded fetch behavior and the auth-jwt JWKS/OIDC fetch guard. The KMS vault copy had a related defect in the opposite direction: any `::ffff:` form it did not recognize fell through to a "blocked" first hextet, over-blocking a public mapped address such as `::ffff:808:808`.
99
+
100
+ Resolution:
101
+
102
+ - Each `isBlockedAddress` copy now parses an IPv4-mapped IPv6 address into its 16 octets and normalizes the embedded IPv4 (last 32 bits, recognized only when bytes 0..9 are zero and bytes 10..11 are `0xffff`) before the private/loopback/link-local/metadata check. This handles every textual form: dotted (`::ffff:127.0.0.1`), hex (`::ffff:7f00:1`), bracketed (`[::ffff:7f00:1]`), leading-zero (`::ffff:7f00:0001`), mixed `::` compression, and case-insensitive `ffff`. A genuinely public mapped address (`::ffff:8.8.8.8` == `::ffff:808:808`) classifies as its public v4 and stays allowed.
103
+ - The DELIBERATE 1.1 decoupling is preserved: no satellite imports `haechi/ssrf` (that would raise their `haechi` peer floor and republish them). The SAME normalization is applied to EACH independent copy and the agreement is locked by the parity tests, so the copies stay independent-but-consistent.
104
+
105
+ Closure evidence (new/extended tests):
106
+
107
+ - `tests/ssrf.test.mjs` — the canonical vector table gains hex/dotted/bracketed IPv4-mapped loopback, RFC1918, and metadata vectors plus an allowed public mapped pair, all also asserted equal to the auth-jwt copy (core-vs-auth-jwt parity).
108
+ - `satellites/auth-jwt/auth-jwt.test.mjs` — `createJwtAuthProvider` construction now rejects dotted AND hex IPv4-mapped IPv6 private/metadata hosts, and does NOT SSRF-block a public mapped host.
109
+ - `satellites/crypto-kms/vault.test.mjs` — the documented range table adds the hex mapped private/metadata forms and the public mapped allow cases.
110
+ - `satellites/crypto-kms/ssrf-parity.test.mjs` — a new "IPv4-mapped IPv6 (dotted + hex)" group pins auth-jwt ⇄ crypto-kms agreement; the parity test stays green.
111
+
112
+ Release decision: resolved; this finding no longer blocks release.
113
+
114
+ ### P1-CR-003: Decompressed Body Returned With Compressed Headers
115
+
116
+ Status: Resolved (2026-06-16, toward 1.3.1)
117
+ Affected code: `packages/proxy/index.mjs` unprotected response paths
118
+ Evidence:
119
+
120
+ - Node `fetch()` auto-decompresses gzip/br/deflate bodies.
121
+ - Unprotected and allow/pass-through response paths return the decoded body while preserving original upstream headers.
122
+ - A local gzip upstream repro caused downstream fetch to fail with `incorrect header check`.
123
+
124
+ Impact:
125
+
126
+ The proxy can emit protocol-inconsistent responses. Clients can fail, retry, or mis-handle protected responses. This also complicates any future response-protection decision because safe and unsafe paths differ in header sanitation.
127
+
128
+ Required remediation:
129
+
130
+ - Strip or recompute `content-encoding`, `content-length`, transfer, and compression metadata whenever the body has been read or transformed by Node.
131
+ - Centralize response-header sanitation so protected, unprotected, and allow paths share the same invariant.
132
+ - Add gzip/br response tests for protected and unprotected paths.
133
+
134
+ Resolution evidence:
135
+
136
+ - A single centralized `sanitizeResponseHeaders(upstreamResponse)` (`packages/proxy/index.mjs`, generalizing the former `streamingResponseHeaders`) strips `content-encoding`, `content-length`, `transfer-encoding`, and hop-by-hop headers (`connection`/`keep-alive`/`te`/`trailer`/`upgrade`/`proxy-authenticate`). It is applied on EVERY response path: streaming pass-through, the inspected-stream `writeHead`, the unprotected/forwarded path, the protected JSON path (`transformedJsonHeaders` now strips the full set), and the `failureMode: allow` path. A correct `content-length` is re-set only for a fully-buffered body.
137
+ - Regression tests in `tests/proxy-header-allowlist.test.mjs`: a gzip upstream response (Node fetch auto-decompresses) returns with no `content-encoding` and a downstream fetch reads the plain body on BOTH the pass-through and the unprotected/forwarded paths.
138
+
139
+ Release decision: blocks release until fixed. Resolved.
140
+
141
+ ### P1-CR-004: Streaming Pass-Through Is Buffered And Unbounded
142
+
143
+ Status: Resolved (2026-06-16, toward 1.3.1)
144
+ Affected code: `packages/proxy/index.mjs` streaming `pass-through` branch
145
+ Evidence:
146
+
147
+ - The pass-through branch reads `await readUpstreamBody(upstreamResponse)` and then writes `response.end(rawBody)`.
148
+ - No response `maxBytes` limit is applied in that path.
149
+ - True SSE/NDJSON streaming is delayed until the upstream closes.
150
+
151
+ Impact:
152
+
153
+ The setting name promises pass-through streaming, but the implementation creates full-response buffering. A long-lived or malicious stream can hold memory and connection resources indefinitely.
154
+
155
+ Required remediation:
156
+
157
+ - Either implement true bounded streaming pass-through or rename/fail-closed the mode until implemented.
158
+ - Apply byte and duration limits to all raw upstream-body reads.
159
+ - Add tests for long-lived stream behavior, response-size overrun, and cancellation on client disconnect.
160
+
161
+ Resolution evidence:
162
+
163
+ - The pass-through branch now does TRUE bounded streaming (`pipeUpstreamBodyBounded` in `packages/proxy/index.mjs`): the upstream body is piped to the client response as it arrives, with a running byte count against `streamingPassThroughMaxBytes(config)` (reuses `responseProtection.maxBytes`). Exceeding the cap cancels the upstream reader and destroys the client response (fail-closed on size); downstream backpressure is respected via `response.write` + `drain`. The former `readUpstreamBody(...)` + `response.end(rawBody)` full-buffering is removed from this path.
164
+ - The unprotected/forwarded raw-body read in `maybeProtectResponse` now also passes the same byte cap to `readUpstreamBody({ maxBytes })` and fails closed (502 `haechi_response_too_large`) on `tooLarge`, so no raw upstream-body read lacks a cap.
165
+ - Regression tests in `tests/proxy-header-allowlist.test.mjs`: an oversize pass-through stream (no content-length, > 8× the cap) is bounded/aborted near the cap and never delivers the full stream; the unprotected/forwarded path returns 502 `response_body_too_large` on an oversize buffered body.
166
+
167
+ Release decision: blocks release until fixed or the mode is disabled by default and documented as unavailable. Resolved.
168
+
169
+ ### P1-CR-005: Streaming Inspect Raw-Passes Non-JSON Frames
170
+
171
+ Status: Resolved (2026-06-16, toward 1.3.1)
172
+ Affected code: `packages/stream-filter/index.mjs`, `packages/core/index.mjs`
173
+ Evidence (was):
174
+
175
+ - SSE parser returned non-JSON `data:` frames with `ok: false`.
176
+ - The inspect flow passed failed-parse frames through raw (`if (!parsed.ok) { sink.write(frame.raw); return; }`).
177
+ - A local repro with `data: minji.kim@example.com\n\n` produced `blocked: false` and leaked the email.
178
+
179
+ Impact (was):
180
+
181
+ When streaming inspection was enabled, plain-text SSE or NDJSON-like frames could bypass PII/secret protection. This weakened the value of streaming hardening because malformed, non-JSON, or provider-specific frames may carry sensitive text.
182
+
183
+ Resolution:
184
+
185
+ - `parseFrame` (`packages/stream-filter/index.mjs`) now distinguishes a CONTROL frame from a non-JSON CONTENT frame. CONTROL is an explicit allowlist with no inspectable text: the SSE `[DONE]` sentinel, a comment-only frame (only `:`/field lines, no `data:`), and an empty/whitespace/keepalive frame. It returns `{ ok:false, control:true, text:null }` for those and `{ ok:false, control:false, text }` (the reconstructed `data:` payload) for a non-JSON CONTENT frame.
186
+ - `handleFrame`'s parse-failed branch passes CONTROL frames through raw (unchanged), but INSPECTS a non-JSON CONTENT frame as text: it calls a new `protector.protectText(text)` (single-shot detect → decide → tally → transform), re-emits `data: <protected text>` via `serializeTextFrame` (preserving `event:`/`id:`/`:` lines and re-emitting a multi-line payload as multiple `data:` lines), and fails the stream closed (`blocked = true`) on a block-action detection.
187
+ - `createStreamProtector` (`packages/core/index.mjs`) gains `protectText(text)`, which reuses the existing `transformSegment` logic. It is DISTINCT from the delta-channel `push`/`flush` cross-frame buffer — it never touches `pending` — so inspecting a non-JSON frame's text cannot corrupt the JSON delta sliding-buffer state. Per-frame text inspection closes the bypass; cross-frame buffering of arbitrary non-JSON frames is out of scope (the delta channel keeps its own buffer; noted in code).
188
+ - The response-direction marker skip and the audit tally are preserved because `protectText` runs the same `transformSegment` with the protector's response-direction `context`, so a tokenized round-trip (`[REDACTED:…]`, `[TOKEN:…]`) echoed by the model is not re-flagged. The JSON path (delta channel, `protectFrameExtras`, cross-frame sliding buffer, `event:`-line preservation) is unchanged.
189
+
190
+ Closure evidence:
191
+
192
+ - `tests/stream-filter.test.mjs` adds: a plain-text SSE `data: <email>` frame is redacted (not leaked); a plain-text frame with a `card: block` action BLOCKS the stream; malformed/partial JSON with PII is inspected as text; an NDJSON non-JSON content frame with PII is inspected; comment-only/keepalive/`event:` control frames pass untouched; a tokenized-round-trip marker is not re-flagged. The existing within-frame and cross-frame JSON delta tests, `[DONE]`/keepalive pass-through, and report-only tests stay green.
193
+ - `tests/proxy-streaming.test.mjs` adds an end-to-end repro: an upstream emitting `data: minji.kim@example.com\n\n` (plain text) is redacted to `[REDACTED:email]` through the proxy, `stream_inspected` is audited, and the audit chain verifies with no plaintext.
194
+ - Follow-up (adversarial verify caught a residual leak in the first cut): a **trim-mismatch** let a leading-whitespace `data:` line (` data: <pii>`) be parsed+redacted but then re-emitted VERBATIM by the serializers (which used a stricter `startsWith("data:")` on the untrimmed raw line), leaking the original — and the same class affected the JSON `serializeFrame`. Fixed by a single shared lenient matcher `SSE_DATA_LINE` / `sseDataPayload` used by `parseFrame` AND both serializers, so a ` data:`/`\tdata:` line is always recognized and replaced, never emitted verbatim. Also hardened `handleFrame` to route a bare PRIMITIVE JSON frame (e.g. `data: "<pii>"`) to text inspection instead of the object delta path (which would throw an uncaught `setByPath`-on-string-root TypeError). Regression tests added in `tests/stream-filter.test.mjs` (leading-space/tab `data:` plaintext, leading-space JSON non-delta field, bare-primitive JSON).
195
+
196
+ Release decision: blocks release until fixed. Resolved.
197
+
198
+ ### P2-CR-006: MCP Wrap Inherits Child `stderr`
199
+
200
+ Status: Resolved (2026-06-16, toward 1.3.1)
201
+ Affected code: `packages/cli/bin/haechi.mjs` `mcpWrapCommand()`
202
+ Evidence:
203
+
204
+ - Child MCP server is spawned with `stdio: ["pipe", "pipe", "inherit"]`.
205
+ - `stderr` is not filtered, audited, redacted, or tokenized.
206
+
207
+ Impact:
208
+
209
+ Sensitive values printed by an MCP server can bypass Haechi controls and appear in the parent terminal, editor logs, or process supervisor logs. This may be acceptable as an explicit local-process boundary, but it is currently not called out strongly enough.
210
+
211
+ Resolution / closure evidence:
212
+
213
+ - `haechi mcp-wrap` gains an explicit `--stderr filter|drop|inherit` flag (default `filter`). `filter` pipes the child's stderr and runs each complete line through the same protection (`runtime.haechi.createStreamProtector().protectText`) before re-emitting to the parent's stderr — redact/mask detected secrets/PII in place, drop a line entirely on a block-action detection — with partial lines buffered across chunk boundaries (split on `\n`, trailing partial flushed on end) and re-emitted in source order. `drop` discards child stderr (consumed via `resume()` so the child never stalls); `inherit` keeps the prior raw passthrough as an explicit, documented opt-in local-process boundary; an unknown `--stderr` value throws a clear fail-closed error before any child is spawned. The stderr filter path records nothing to the audit sink (no plaintext reaches the audit log), and the stdin/stdout JSON-RPC wrap behavior is byte-identical. `COMMAND_HELP` documents the flag, including that `filter` follows the configured policy mode (dry-run/report-only detects but does not transform).
214
+ - `tests/mcp-wrap.test.mjs` adds four cases (filter redacts/masks/drops so the parent never sees a raw secret/PII/card/phone value; drop emits nothing; inherit passes raw; unknown value exits non-zero). Adversarial verify confirmed the default is now `filter` (was the vulnerable `inherit`), chunk-split secrets are reassembled and protected, and block-action lines are dropped not leaked.
215
+
216
+ Release decision: resolved.
217
+
218
+ ### P2-CR-007: Existing Key File Not Validated During Init
219
+
220
+ Status: Resolved (2026-06-16, toward 1.3.1)
221
+ Affected code: `packages/crypto/index.mjs` `initLocalKeyFile()`
222
+ Evidence:
223
+
224
+ - Existing key-file path returns success without validating that active/retired keys are parseable and usable.
225
+
226
+ Impact:
227
+
228
+ `haechi init` can report success for corrupted-but-parseable key material. Users discover the problem later when encryption, decryption, token vault, or bundle verification fails.
229
+
230
+ Resolution / closure evidence:
231
+
232
+ - The provider's existing key-load/validation logic (JSON parse, per-key base64url + 32-byte check, active-key resolution) was extracted into a shared module-level `loadKeyFile(keyFile, { requireActive })` that the private `loadKeys()` now delegates to (preserving its historical `keys[0]` fallback). `initLocalKeyFile`'s existing-file non-force path now calls `loadKeyFile` with `requireActive: true` before returning, throwing a specific error per defect (corrupted JSON; "No active key found in local key file"; "AES-256-GCM local key must be 32 bytes" for an active or retired key). A valid existing file stays non-destructive and returns the same `{ created: false, keyFile }` shape; `--force` rotation (retire-not-delete) is unchanged.
233
+ - `tests/crypto.test.mjs` adds four cases: corrupted JSON throws; missing active key throws; wrong-length active key throws; a valid file with retired keys succeeds byte-for-byte unchanged. Adversarial verify confirmed each defect is caught and the valid path is non-destructive.
234
+
235
+ Release decision: resolved.
236
+
237
+ ### P2-CR-008: Satellite Packaging Check Misses `manifest.bin`
238
+
239
+ Status: Resolved (2026-06-16, toward 1.3.1)
240
+ Affected code: `scripts/check-satellite-packaging.mjs`
241
+ Evidence:
242
+
243
+ - Package checks validate exported files but do not prove that `manifest.bin` points to present executable files.
244
+
245
+ Impact:
246
+
247
+ A satellite package can pass the local packaging check while shipping a broken CLI entrypoint. This is a release quality risk, especially as auth/KMS/dashboard satellites expand.
248
+
249
+ Resolution / closure evidence:
250
+
251
+ - `evaluateSatellitePackaging()` in `scripts/check-satellite-packaging.mjs` now validates every `manifest.bin` target against the packed-file set: both the string form (`bin: "bin/x.mjs"`) and the object-map form (`bin: { name: "bin/x.mjs" }`) are normalized the same way as `files`/`exports`, and a clear problem is reported for any bin target not present in the tarball. Existing checks are unchanged.
252
+ - `tests/satellite-packaging-gate.test.mjs` adds positive (present bin → no problem) and negative (missing bin, string + object-map forms → bin-specific problem) cases. Adversarial verify confirmed a mutation removing the bin-check block fails the negative test.
253
+
254
+ Release decision: resolved.
255
+
256
+ ### P2-CR-009: Auth Provider Throw Path Test Gap
257
+
258
+ Status: Resolved (2026-06-16, toward 1.3.1)
259
+ Affected code: `packages/proxy/index.mjs` auth handling, `tests/proxy-auth.test.mjs`
260
+ Evidence:
261
+
262
+ - Runtime wraps `authProvider.authenticate()` errors as fail-closed `haechi_auth_provider_error`.
263
+ - Existing tests cover several auth outcomes but not provider exceptions.
264
+
265
+ Impact:
266
+
267
+ Future auth-provider changes can accidentally leak raw errors, fail open, or return inconsistent audit status without tests catching it.
268
+
269
+ Resolution / closure evidence:
270
+
271
+ - `tests/proxy-auth.test.mjs` adds a regression test that injects an `authProvider` whose `authenticate()` throws and asserts the proxy fails closed: the request is rejected (not forwarded upstream) with a generic client error, the audit event records the fail-closed status `haechi_auth_provider_error`, and no raw error/stack and no raw subject/issuer leak into the audit event. Adversarial verify confirmed a fail-open mutant (forwards upstream / returns 200) and an audit-leak mutant both make the test fail.
272
+
273
+ Release decision: resolved.
274
+
275
+ ### P2-CR-010: Process-Isolated Sandbox Quota Test Gap
276
+
277
+ Status: Resolved (2026-06-16, toward 1.3.1)
278
+ Affected code: `packages/plugin/process-sandbox.mjs`
279
+ Evidence:
280
+
281
+ - Oversized result and over-capacity branches are not mirrored with the same focused coverage as worker sandbox tests.
282
+
283
+ Impact:
284
+
285
+ Process isolation is a security boundary for future plugin work. Missing parity tests increase the chance of regressions in denial-of-service controls.
286
+
287
+ Resolution / closure evidence:
288
+
289
+ - `tests/plugin-process-sandbox.test.mjs` (with a crash fixture added to `tests/helpers/sandbox-fixtures.mjs`) adds isolated-process parity tests mirroring the worker-sandbox DoS-control coverage: oversized result denied, queue/over-capacity rejected, timeout terminated, and child-crash fail-closed (a crash mid-call surfaces as a `crash`-caused denial without killing sibling calls). Adversarial verify confirmed mutations disabling the oversize / capacity / timeout / crash guards each fail the corresponding test (the crash boundary is pinned by the mid-call crash test).
290
+
291
+ Release decision: resolved.
292
+
293
+ ### P2-CR-011: Audit Chain Middle-Tamper Test Gap
294
+
295
+ Status: Resolved (2026-06-16, toward 1.3.1)
296
+ Affected code: `packages/audit/index.mjs` `verifyAuditChain()`
297
+ Evidence:
298
+
299
+ - Existing coverage does not focus on middle-record tampering branches.
300
+
301
+ Impact:
302
+
303
+ Audit integrity is a core claim. The chain-verification code is present, but branch-specific tests should prove it rejects middle-record tampering, missing previous hashes, and hash mismatches.
304
+
305
+ Resolution / closure evidence:
306
+
307
+ - `tests/audit-chain-tamper.test.mjs` writes a real multi-record audit log via the sink, then tampers a MIDDLE record and asserts `verifyAuditChain` returns `{ valid: false }` with the correct reason for each branch: middle-record content mutation (stale `eventHash`), missing `previousHash`, wrong `previousHash`, and wrong `integrity` hash. The known tail-truncation limitation (trailing-record removal is detectable only via the separate append-only anchor stream, not the chain alone) is kept explicit. Adversarial verify confirmed the logs are produced by the real sink and the assertions pin each tamper branch.
308
+
309
+ Release decision: resolved.
310
+
311
+ ### P2-CR-012: KMS Vault IPv6 Loopback Test Gap
312
+
313
+ Status: Resolved (2026-06-16, toward 1.3.1)
314
+ Affected code: `satellites/crypto-kms/vault.mjs`
315
+ Evidence (was):
316
+
317
+ - Localhost carve-out coverage proved IPv4 loopback behavior but not IPv6 loopback variants.
318
+
319
+ Impact (was):
320
+
321
+ The vault guard is security-sensitive and has slightly different URL parsing logic from the core SSRF guard. IPv6-specific tests were needed to prevent future divergence.
322
+
323
+ Resolution / closure evidence:
324
+
325
+ - `satellites/crypto-kms/vault.test.mjs` adds a dedicated test, "isBlockedAddress enforces the IPv6 loopback policy (::1, [::1], dotted + hex mapped) — P2-CR-012", covering bare `::1`, bracketed `[::1]`, dotted `::ffff:127.0.0.1` (and its bracketed form), and hex `::ffff:7f00:1` / `::ffff:7f00:0001` (and its bracketed form), all per the intended vault policy (blocked), and asserting that a public IPv4-mapped address (`::ffff:8.8.8.8` / `::ffff:808:808`) is NOT over-blocked.
326
+ - The vault range table is extended with the hex mapped private/metadata forms and the public mapped allow cases, and `satellites/crypto-kms/ssrf-parity.test.mjs` pins the dotted+hex agreement with the auth-jwt copy — the intentional non-IP fail-closed divergence stays explicitly pinned, so future divergence is caught.
327
+
328
+ Release decision: resolved; the test gap is closed.
329
+
330
+ ### P2-CR-013: SSE Multi-Line `data:` Join Semantics
331
+
332
+ Status: Resolved (2026-06-16, toward 1.3.1)
333
+ Affected code: `packages/stream-filter/index.mjs`
334
+ Evidence (was):
335
+
336
+ - The SSE parser joined multiple `data:` lines with `join("")`.
337
+ - The SSE processing model joins multiple data lines with newline separators.
338
+
339
+ Impact (was):
340
+
341
+ Valid multi-line SSE events could be mutated before parsing or inspection. This can cause false negatives, false positives, or malformed forwarded events.
342
+
343
+ Resolution:
344
+
345
+ - `parseFrame` now joins multiple `data:` lines with `join("\n")` (the SSE spec separator) and strips only the single spec-defined leading space per line (`replace(/^ /, "")` instead of `trim()`, so interior/trailing text whitespace is not corrupted). A multi-line JSON event still `JSON.parse`s because newlines are valid JSON whitespace between tokens / inside the reconstructed value; a multi-line plain-text event is reconstructed with its newlines before text inspection. The non-JSON CONTENT re-serializer (`serializeTextFrame`) re-emits a multi-line protected payload as multiple `data:` lines, so the newline survives the round-trip.
346
+
347
+ Closure evidence:
348
+
349
+ - `tests/stream-filter.test.mjs` adds a multi-line `data:` JSON event (split across two `data:` lines) that still parses and is protected, and a multi-line plain-text `data:` event whose PII (on the second line) is caught and re-emitted with two `data:` lines preserved.
350
+
351
+ Release decision: should be fixed with the streaming remediation group. Resolved.
352
+
353
+ ## Remediation Order
354
+
355
+ 1. Fix `P0-CR-001` first because it is a direct credential-boundary leak.
356
+ 2. Fix `P1-CR-002` before adding new URL-fetching surfaces such as auth-provider discovery or KMS integrations.
357
+ 3. Fix `P1-CR-003` and `P1-CR-004` together because they share response-forwarding invariants.
358
+ 4. Fix `P1-CR-005` and `P2-CR-013` together as the streaming-inspection group.
359
+ 5. Resolve `P2-CR-006` before recommending MCP wrap for sensitive local tools.
360
+ 6. Finish P2 key, packaging, and regression-test gaps before the next npm publish.
361
+
362
+ ## Closure Rules
363
+
364
+ An item can move to `Resolved` only when all of the following are true:
365
+
366
+ - Code or documentation remediation is merged.
367
+ - A focused regression test or explicit non-test rationale is recorded.
368
+ - The release-gate register links the remediation evidence.
369
+ - Any accepted residual risk is moved into the threat model or shared-responsibility documentation with operator guidance.
370
+
371
+ ## Traceability
372
+
373
+ This document is linked from:
374
+
375
+ - `docs/current/risk-register-release-gate.md`
376
+ - `docs/current/risk-register-release-gate.ko.md`
377
+
@@ -41,6 +41,7 @@
41
41
  | `target.type` | `llm-http` \| `openai-compatible` \| `vllm-openai` \| `ollama` \| `llama-cpp` \| `anthropic` \| `gemini` | `llm-http` | 프로토콜 adapter를 선택합니다. `llm-http`는 `openai-compatible`의 별칭입니다. `anthropic`은 Anthropic Messages API(`/v1/messages`, `/v1/messages/count_tokens`, `/v1/complete`)를 대상으로 합니다. 클라이언트가 Anthropic의 `x-api-key`/`anthropic-version` 헤더를 제공하면 프록시가 이를 그대로 전달합니다. `gemini`는 Google Gemini API를 대상으로 합니다. 엔드포인트는 모델명이 경로에 포함되고 `:method` 접미사를 사용하는 형태입니다(`/v1beta/models/{model}:generateContent`, `:streamGenerateContent`(SSE), `:countTokens`, `:embedContent`, `:batchEmbedContents`; `/v1` 또는 `/v1beta` 접두사, 임의의 `{model}`). 클라이언트가 Gemini의 `x-goog-api-key`(또는 `?key=`)를 제공하면 프록시가 이를 그대로 전달합니다. 알 수 없는 값은 로드 시 **fail-closed**로 처리됩니다. |
42
42
  | `target.adapter` | 동일한 값 집합 | `openai-compatible` | adapter를 명시적으로 지정합니다. 보통은 설정하지 않고 `type`이 결정하도록 두면 됩니다. |
43
43
  | `target.upstream` | URL 문자열 | `http://127.0.0.1:9999` | proxy가 요청을 전달하는 유일한 upstream입니다. 요청 대상은 origin-form 경로여야 하며, 절대 URL 대상은 거부됩니다(SSRF 방어). |
44
+ | `target.forwardHeaders` | 소문자 헤더 이름 배열 | 미설정(`[]`) | 내장 upstream 헤더 허용목록의 **추가** 확장입니다. proxy는 명시적 허용목록만 upstream으로 전달합니다(제공자/어댑터 헤더: `x-api-key`, `anthropic-version`, `anthropic-beta`, `x-goog-api-key`, `openai-organization`, `openai-beta`, `accept`, `accept-language`, `user-agent`, 그리고 `application/json`으로 재작성되는 `content-type`). 클라이언트 `Authorization`은 `auth.provider: none`일 때만(upstream 제공자 키이므로) 전달되고 그 외에는(gateway credential이므로) 폐기됩니다. `Cookie`/`Set-Cookie`/`Proxy-Authorization`와 hop-by-hop 헤더는 항상 폐기됩니다. 특이한 upstream을 위한 추가 소문자 이름을 여기에 나열하세요. **fail-closed:** 소문자 비어있지 않은 문자열 배열이어야 하며, 항상 폐기되는 credential/hop-by-hop 헤더를 지정할 수 없습니다. |
44
45
 
45
46
  ## `proxy`
46
47
 
@@ -406,7 +407,7 @@ haechi proxy --config haechi.config.json --host 0.0.0.0 --allow-remote-bind
406
407
 
407
408
  ## 검증 요약
408
409
 
409
- 다음은 로드 시 오류(fail-closed)를 발생시킵니다: 알 수 없는 `keys.provider`; 빈 `proxy.host`; 범위를 벗어난 `proxy.port`; boolean이 아닌 `proxy.trustForwardedProto`; non-`null`이지만 object가 아니거나, `keyFile`만 있고 `certFile`이 없거나(또는 그 반대), `pfxFile`을 `keyFile`/`certFile`과 함께 쓰거나, 읽을 수 없는 파일을 지정하거나, 사용 가능한 자료 `((key && cert) 또는 pfx)`로 해석되지 않는 `proxy.tls`; `jsonl`이 아닌 `audit.sink`; `local`이 아닌 `tokenVault.provider`; 잘못된 `revealPolicy`; 양수가 아닌 `retentionDays`; boolean이 아닌 `deterministic`/`detokenizeResponses`; 비어 있거나 문자열이 아닌 `deterministicTypes`; 비어 있거나 문자열이 아닌 `mcp.allowedMethods`; boolean이 아닌 `mcp.*` 플래그; 알 수 없는 `privacy.profile`; 잘못된 `responseProtection.failureMode`; 양수가 아닌 `responseProtection.maxBytes`; boolean이 아닌 `responseProtection.scanNumbers`; 잘못된 `streaming.requestMode`; 잘못된 `streaming.responseMode`; 양수가 아닌 `streaming.maxMatchBytes`; 잘못된 `auth.provider`; 빈 `auth.store`; 문자열이 아닌 `auth.allowedLabelKeys`; 객체가 아닌 `policy.profiles`; 유효한 `default` 없는 `policy.profileBinding`; 문자열이 아닌 `policy.modelAllowlist`; 양수가 아닌 `policy.rate.requestsPerMinute`; 양수가 아닌 `limits.maxRequestBytes`/`limits.upstreamTimeoutMs`/`limits.maxNestingDepth`; 음수이거나 정수가 아닌 `limits.maxInFlight`/`limits.shutdownGraceMs`; `null`이 아니면서 음수이거나 정수가 아닌 `limits.requestTimeoutMs`/`limits.headersTimeoutMs`; 양수 정수가 아니거나 **지원 범위를 넘는** `configVersion`; 알 수 없는 `target.type`/`adapter`; 안전하지 않은 커스텀 정규식; `allowUnsafeOverrides` 없이 action을 약화하려는 시도; `text`/`json`이 아닌 `logging.format`; boolean이 아닌 `metrics.enabled`; 잘못된 `HAECHI_*` 환경변수 오버레이 값(잘못된 `HAECHI_PROXY_PORT`, 알 수 없는 `HAECHI_MODE`, 형식이 잘못된 `HAECHI_UPSTREAM` 등).
410
+ 다음은 로드 시 오류(fail-closed)를 발생시킵니다: 알 수 없는 `keys.provider`; 빈 `proxy.host`; 범위를 벗어난 `proxy.port`; boolean이 아닌 `proxy.trustForwardedProto`; non-`null`이지만 object가 아니거나, `keyFile`만 있고 `certFile`이 없거나(또는 그 반대), `pfxFile`을 `keyFile`/`certFile`과 함께 쓰거나, 읽을 수 없는 파일을 지정하거나, 사용 가능한 자료 `((key && cert) 또는 pfx)`로 해석되지 않는 `proxy.tls`; `jsonl`이 아닌 `audit.sink`; `local`이 아닌 `tokenVault.provider`; 잘못된 `revealPolicy`; 양수가 아닌 `retentionDays`; boolean이 아닌 `deterministic`/`detokenizeResponses`; 비어 있거나 문자열이 아닌 `deterministicTypes`; 비어 있거나 문자열이 아닌 `mcp.allowedMethods`; boolean이 아닌 `mcp.*` 플래그; 알 수 없는 `privacy.profile`; 잘못된 `responseProtection.failureMode`; 양수가 아닌 `responseProtection.maxBytes`; boolean이 아닌 `responseProtection.scanNumbers`; 잘못된 `streaming.requestMode`; 잘못된 `streaming.responseMode`; 양수가 아닌 `streaming.maxMatchBytes`; 잘못된 `auth.provider`; 빈 `auth.store`; 문자열이 아닌 `auth.allowedLabelKeys`; 객체가 아닌 `policy.profiles`; 유효한 `default` 없는 `policy.profileBinding`; 문자열이 아닌 `policy.modelAllowlist`; 양수가 아닌 `policy.rate.requestsPerMinute`; 양수가 아닌 `limits.maxRequestBytes`/`limits.upstreamTimeoutMs`/`limits.maxNestingDepth`; 음수이거나 정수가 아닌 `limits.maxInFlight`/`limits.shutdownGraceMs`; `null`이 아니면서 음수이거나 정수가 아닌 `limits.requestTimeoutMs`/`limits.headersTimeoutMs`; 양수 정수가 아니거나 **지원 범위를 넘는** `configVersion`; 알 수 없는 `target.type`/`adapter`; 소문자 비어있지 않은 문자열 배열이 아니거나 항상 폐기되는 credential/hop-by-hop 헤더를 지정한 `target.forwardHeaders`; 안전하지 않은 커스텀 정규식; `allowUnsafeOverrides` 없이 action을 약화하려는 시도; `text`/`json`이 아닌 `logging.format`; boolean이 아닌 `metrics.enabled`; 잘못된 `HAECHI_*` 환경변수 오버레이 값(잘못된 `HAECHI_PROXY_PORT`, 알 수 없는 `HAECHI_MODE`, 형식이 잘못된 `HAECHI_UPSTREAM` 등).
410
411
 
411
412
  # Satellite 운영자 설정 (0.9)
412
413
 
@@ -41,6 +41,7 @@
41
41
  | `target.type` | `llm-http` \| `openai-compatible` \| `vllm-openai` \| `ollama` \| `llama-cpp` \| `anthropic` \| `gemini` | `llm-http` | Selects the protocol adapter. `llm-http` aliases `openai-compatible`. `anthropic` targets the Anthropic Messages API (`/v1/messages`, `/v1/messages/count_tokens`, `/v1/complete`); the client supplies Anthropic's `x-api-key`/`anthropic-version` headers and the proxy forwards them. `gemini` targets the Google Gemini API, whose endpoints are model-in-path with a `:method` suffix (`/v1beta/models/{model}:generateContent`, `:streamGenerateContent` (SSE), `:countTokens`, `:embedContent`, `:batchEmbedContents`; `/v1` or `/v1beta` prefix, arbitrary `{model}`); the client supplies Gemini's `x-goog-api-key` (or `?key=`) and the proxy forwards it. Unknown values **fail closed** at load. |
42
42
  | `target.adapter` | same set | `openai-compatible` | Explicit adapter override; usually leave unset and let `type` decide. |
43
43
  | `target.upstream` | URL string | `http://127.0.0.1:9999` | The only upstream the proxy forwards to. Request targets must be origin-form paths; absolute-URL targets are rejected (SSRF guard). |
44
+ | `target.forwardHeaders` | array of lowercase header names | unset (`[]`) | **Additive** extension of the built-in upstream header allowlist. The proxy forwards only an explicit allowlist to the upstream (provider/adapter headers: `x-api-key`, `anthropic-version`, `anthropic-beta`, `x-goog-api-key`, `openai-organization`, `openai-beta`, `accept`, `accept-language`, `user-agent`, and `content-type` rewritten to `application/json`); the client `Authorization` is forwarded only when `auth.provider: none` (it is the upstream provider key) and dropped otherwise (it is the gateway credential); `Cookie`/`Set-Cookie`/`Proxy-Authorization` and hop-by-hop headers are always dropped. List extra lowercase names here for an unusual upstream. **Fail-closed:** must be an array of lowercase non-empty strings and may NOT name an always-dropped credential/hop-by-hop header. |
44
45
 
45
46
  ## `proxy`
46
47
 
@@ -406,7 +407,7 @@ With `trustForwardedProto: true`, Haechi **refuses any request whose `X-Forwarde
406
407
 
407
408
  ## Validation cheatsheet
408
409
 
409
- These throw at load (fail-closed): unknown `keys.provider`; empty `proxy.host`; out-of-range `proxy.port`; non-boolean `proxy.trustForwardedProto`; a `proxy.tls` that is non-`null` but not an object, sets `keyFile` without `certFile` (or vice-versa), mixes `pfxFile` with `keyFile`/`certFile`, names an unreadable file, or does not resolve to usable material `((key && cert) or pfx)`; non-`jsonl` `audit.sink`; non-`local` `tokenVault.provider`; bad `revealPolicy`; non-positive `retentionDays`; non-boolean `deterministic`/`detokenizeResponses`; empty/non-string `deterministicTypes`; empty/non-string `mcp.allowedMethods`; non-boolean `mcp.*` flags; unknown `privacy.profile`; bad `responseProtection.failureMode`; non-positive `responseProtection.maxBytes`; non-boolean `responseProtection.scanNumbers`; bad `streaming.requestMode`/`streaming.responseMode`; non-positive `streaming.maxMatchBytes`; bad `auth.provider`; empty `auth.store`; non-string `auth.allowedLabelKeys`; non-object `policy.profiles`; `policy.profileBinding` without a valid `default`; non-string `policy.modelAllowlist`; non-positive `policy.rate.requestsPerMinute`; non-positive `limits.maxRequestBytes`/`limits.upstreamTimeoutMs`/`limits.maxNestingDepth`; negative or non-integer `limits.maxInFlight`/`limits.shutdownGraceMs`; non-`null`/negative/non-integer `limits.requestTimeoutMs`/`limits.headersTimeoutMs`; non-positive-integer or **newer-than-supported** `configVersion`; unknown `target.type`/`adapter`; unsafe custom regex; weakening action without `allowUnsafeOverrides`; non-`text`/`json` `logging.format`; non-boolean `metrics.enabled`; an invalid `HAECHI_*` env overlay value (bad `HAECHI_PROXY_PORT`, unknown `HAECHI_MODE`, malformed `HAECHI_UPSTREAM`, …).
410
+ These throw at load (fail-closed): unknown `keys.provider`; empty `proxy.host`; out-of-range `proxy.port`; non-boolean `proxy.trustForwardedProto`; a `proxy.tls` that is non-`null` but not an object, sets `keyFile` without `certFile` (or vice-versa), mixes `pfxFile` with `keyFile`/`certFile`, names an unreadable file, or does not resolve to usable material `((key && cert) or pfx)`; non-`jsonl` `audit.sink`; non-`local` `tokenVault.provider`; bad `revealPolicy`; non-positive `retentionDays`; non-boolean `deterministic`/`detokenizeResponses`; empty/non-string `deterministicTypes`; empty/non-string `mcp.allowedMethods`; non-boolean `mcp.*` flags; unknown `privacy.profile`; bad `responseProtection.failureMode`; non-positive `responseProtection.maxBytes`; non-boolean `responseProtection.scanNumbers`; bad `streaming.requestMode`/`streaming.responseMode`; non-positive `streaming.maxMatchBytes`; bad `auth.provider`; empty `auth.store`; non-string `auth.allowedLabelKeys`; non-object `policy.profiles`; `policy.profileBinding` without a valid `default`; non-string `policy.modelAllowlist`; non-positive `policy.rate.requestsPerMinute`; non-positive `limits.maxRequestBytes`/`limits.upstreamTimeoutMs`/`limits.maxNestingDepth`; negative or non-integer `limits.maxInFlight`/`limits.shutdownGraceMs`; non-`null`/negative/non-integer `limits.requestTimeoutMs`/`limits.headersTimeoutMs`; non-positive-integer or **newer-than-supported** `configVersion`; unknown `target.type`/`adapter`; a `target.forwardHeaders` that is not an array of lowercase non-empty strings or that names an always-dropped credential/hop-by-hop header; unsafe custom regex; weakening action without `allowUnsafeOverrides`; non-`text`/`json` `logging.format`; non-boolean `metrics.enabled`; an invalid `HAECHI_*` env overlay value (bad `HAECHI_PROXY_PORT`, unknown `HAECHI_MODE`, malformed `HAECHI_UPSTREAM`, …).
410
411
 
411
412
  # Satellite operator configuration (0.9)
412
413
 
@@ -1,20 +1,22 @@
1
1
  # Haechi 리스크 레지스터 및 릴리스 게이트
2
2
 
3
3
  - 문서 상태: Living document(core 1.3.x 추적)
4
- - 작성일: 2026-06-11
4
+ - 작성일: 2026-06-16
5
5
  - 기준 버전: 1.3.x
6
6
  - 기준 브랜치: `main`
7
7
 
8
8
  ## 1. 현재 판단
9
9
 
10
- Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트(G2, `haechi@0.3.2`)부터 G6(1.1.0 plugin capability 강제)까지 모든 게이트가 통과되었으며, 아래 게이트 이력은 감사 추적으로 보존합니다. 1.0.0은 strict semver 하의 frozen API 계약을 선언하고(문서화된 deprecation 정책과 freeze 가드 `tests/api-contract.test.mjs` 포함), signed·sandboxed `authProvider` plugin에 한해 dynamic-loading 금지를 좁게 해제했습니다. 1.1.0은 커널 수준 capability 거부를 갖춘 opt-in `process-isolated` plugin 런타임을 추가했습니다. stable 표현을 막던 조건 — 1.0 API 안정성, 외부 `cryptoProvider`/KMS reference adapter(`haechi-crypto-kms`), stream-aware enforcement(`streaming.requestMode: "inspect"`) — 은 모두 갖춰졌습니다. Haechi는 여전히 컴플라이언스를 보장하지 않는 self-hosted 보안 toolkit이며, 운영 배포는 네트워크 접근 통제, upstream 인증, key custody를 직접 책임집니다(threat model §5 참고).
10
+ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트(G2, `haechi@0.3.2`)부터 G8(1.3.0 backend + detection coverage expansion)까지 모든 게이트가 통과되었으며, 아래 게이트 이력은 감사 추적으로 보존합니다. 1.0.0은 strict semver 하의 frozen API 계약을 선언하고(문서화된 deprecation 정책과 freeze 가드 `tests/api-contract.test.mjs` 포함), signed·sandboxed `authProvider` plugin에 한해 dynamic-loading 금지를 좁게 해제했습니다. 1.1.0은 커널 수준 capability 거부를 갖춘 opt-in `process-isolated` plugin 런타임을 추가했습니다. stable 표현을 막던 조건 — 1.0 API 안정성, 외부 `cryptoProvider`/KMS reference adapter(`haechi-crypto-kms`), stream-aware enforcement(`streaming.requestMode: "inspect"`) — 은 모두 갖춰졌습니다. Haechi는 여전히 컴플라이언스를 보장하지 않는 self-hosted 보안 toolkit이며, 운영 배포는 네트워크 접근 통제, upstream 인증, key custody를 직접 책임집니다(threat model §5 참고).
11
+
12
+ **2026-06-16 코드리뷰 보완 — `haechi@1.3.1`로 발행:** 전체 코드리뷰 결과를 `docs/current/code-review-risk-register-2026-06-16.ko.md`에 등록부로 열었습니다. 이 리뷰에서 P0 credential-boundary leak 1건, P1 릴리스 차단 이슈 4건, P2 하드닝/테스트 공백 8건이 확인됐습니다. **13개 `P*-CR-*` 항목이 모두 Resolved이며(§5.7) `haechi@1.3.1` 보완 컷(2026-06-16, attested OIDC publish)으로 발행되었습니다.** G9은 **Pass**입니다. 운영자는 수정 사항(특히 P0-CR-001 프록시 헤더 경계 패치)을 반영하려면 `haechi@1.3.0`에서 `1.3.1`로 업그레이드해야 합니다.
11
13
 
12
14
  | 구분 | 판단 | 이유 |
13
15
  |---|---|---|
14
16
  | GitHub public | 허용 | 보안 한계, threat model, shared responsibility가 문서화됨 |
15
- | GitHub release/tag | 허용 | stable `1.x` 라인; 릴리스 노트가 게이트(G0–G6)를 추적 |
16
- | npm stable | 허용 | 1.0 stable 라벨 조건 frozen API 계약, 외부 KMS reference adapter, stream-aware enforcement 충족; core는 provenance와 함께 발행 |
17
- | production use | 운영자 게이트 | 운영자가 네트워크 접근 통제, 인증/인가, 운영 key custody 앞단에 두면 self-hosted 게이트웨이로 지원; Haechi는 컴플라이언스 보장이 아님 |
17
+ | GitHub release/tag | 허용 (`v1.3.1` 릴리스됨) | `v1.3.1` 보완 컷이 태깅·릴리스됨; §5.7 항목이 모두 Resolved이고 G9은 Pass |
18
+ | npm stable | `haechi@1.3.1` publish됨 | 코드리뷰 보완이 `haechi@1.3.1` attested OIDC publish(2026-06-16)로 발행됨; 이전 `1.3.0`은 수정 이전 동작을 담고 있음 |
19
+ | production use | 운영자 게이트; `1.3.1`로 업그레이드 | 운영자 네트워크 통제, 인가/인증, key custody 있을 때만 지원; `haechi@1.3.0` 운영자는 민감한 제3자 업스트림 트래픽을 프록시로 라우팅하기 전에 프록시 헤더 경계 수정(P0-CR-001)을 반영하도록 `1.3.1`로 업그레이드해야 함 |
18
20
 
19
21
  ## 2. 릴리스 게이트
20
22
 
@@ -29,6 +31,7 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
29
31
  | G6 | 1.1.0 plugin capability 강제 (`process-isolated`) | P1-SEC-027 / P1-SEC-028 mitigated; `process-isolated` 런타임(`--permission` 하 자식, 부여 0, `data:` URL 로드, stdio 무시, JSON-string IPC) + fail-closed `--allow-net` 기능 탐지(`netEnforcement:"require-permission"`) + 코어 `haechi/ssrf` 가드 + 호스트 중개 키 자료 + spawn-storm 서킷 브레이커; fs/net/stdio 레드팀 + SSRF + config 테스트 통과(행동 스위트는 `--allow-net` Node에서 실행, 아니면 fail-closed로 skip); API freeze 통과 유지(additive `./ssrf` export + additive config 키); core는 zero runtime dependency 유지; core 1.1.0 bump(additive + opt-in 마이너) | Pass |
30
32
  | G7 | 1.2.0 신뢰성 강화 트랙 (WS1–WS6) | 탐지 품질 측정+강화(WS2: 라벨 코퍼스 precision/recall `bench:detection` 게이트, 자격증명+국제 PII 커버리지, 하드블록 타입 불변식이 적용된 `filters.minConfidence` / `filters.allowlist`, offset 무결성을 갖춘 NFKC 유니코드 회피 폴딩); WS3 주입 가능한 `rateLimiter` 시임 + bounded fixed-window map; WS4 운영성(`/__haechi/live`+`/ready` 분리, 주입 가능한 `/metrics`, 구조적 로그 + 요청별 `correlationId`, graceful drain, max-in-flight backpressure, env overlay, 하드닝 Dockerfile/compose/runbook, `configVersion`); WS6 proxy TLS / remote-bind 하드닝(`proxy.tls` / `proxy.trustForwardedProto`, fail-closed `assertSafeProxyTransport`) + OWASP-LLM/NIST 컨트롤 매핑 백서 + RFC 9116 `security.txt` + 취약점 공개 경로. 모든 변경은 1.1 동작을 보존하는 기본값 뒤의 additive(`tests/api-contract.test.mjs` 통과); no-plaintext-in-audit 불변식이 텔레메트리까지 확장; core는 zero runtime dependency 유지; core 1.2.0 bump(additive 마이너) | Pass |
31
33
  | G8 | 1.3.0 백엔드 + 탐지 커버리지 확장 | **Anthropic Messages API**(`/v1/messages`, content-block + SSE `delta.text`, `event:` 라인 보존 재직렬화)와 **Google Gemini API**(model-in-path `:generateContent`/`:streamGenerateContent`, 기존 정확-매칭 어댑터를 바이트 동일하게 두는 additive `:method`-suffix 라우트 매처) 프로토콜 어댑터 추가; 탐지 커버리지 확장 — 클라우드/SaaS provider 키(OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored)와 국제 PII(FR/ES/JP + IT/SG/IN/DE/NL 국가 ID, 체크섬 validator), 각 하드블록-대-dial-eligible 결정은 측정된 충돌률 기반(하드블록은 비숫자 앵커 또는 비현실적으로 드문 형태가 필요; 흔한 길이의 bare-digit run은 allowlist로 정리 가능 유지); `bench:throughput` proxy 부하 벤치; `haechi-ratelimit-redis` 공유 저장소 rate-limiter 위성(WS3 시임의 운영 소비자; proxy가 이제 `rateLimiter.allow`를 `await`); `haechi-dashboard`가 요청별 `correlationId` 노출. 모든 변경은 additive — 새 `target.type`/탐지타입/`privacy.profile` *값*이며 새 config 키가 아님(`configVersion`은 `1` 유지); `tests/api-contract.test.mjs` 통과; core는 zero runtime dependency 유지; core 1.3.0 bump(additive 마이너) | Pass |
34
+ | G9 | 2026-06-16 전체 코드리뷰 보완 게이트 (1.3.1로 발행) | `P0-CR-001` 및 `P1-CR-002`부터 `P1-CR-005`까지 해결 또는 책임자 명시 수용; P2 항목은 해결 또는 명시적 non-blocking 근거와 일정 기록; 연결된 등록부 갱신. **13개 `P*-CR-*` 항목이 모두 Resolved이며(§5.7) `haechi@1.3.1`(2026-06-16, attested OIDC publish)로 발행되었습니다; core가 1.3.0 → 1.3.1로 bump(patch, 보완 전용 — API/config 표면 변경 없음, `configVersion`은 `1` 유지)되었습니다.** | Pass (`haechi@1.3.1`, 2026-06-16) |
32
35
 
33
36
  ## 3. P0 배포 차단 리스크 상태
34
37
 
@@ -128,6 +131,26 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
128
131
  | P1-SEC-027 | Plugin capability *강제*: 1.0 `worker_threads` sandbox는 memory/crash 격리뿐이라 악의적 signed plugin이 `fs`/`net`을 써서 credential을 exfiltrate할 수 있음. **P1-SEC-024의 수용된 worker 잔여를 강화** — 1.1이 새 opt-in 런타임에 실제 강제 추가 | Mitigated | `packages/plugin/process-sandbox.mjs` `createProcessIsolatedAuthProvider`/`…Sync`(PR #54): signed `authProvider`가 `--permission` 하 자식 `node`에서 **부여 0**(fs/child-process/worker/addons/wasi 없음, `--allow-net` 없음)으로, `data:` URL 로드(fs 권한 없음 → TOCTOU/symlink 표면 없음), `stdio:['ignore','ignore','ignore','ipc']`(stdout/stderr/fd 유출 채널 없음), 정화 env, JSON-string 전용 IPC + 공유 null-proto sanitizer + 호스트측 keyed-HMAC identity로 실행됩니다. **Node 26 실측 검증**: plugin의 `fs`/`net`/`fetch`/`dns`/`child_process`/`worker`와 `process.binding('tcp_wrap')` 우회가 모두 `ERR_ACCESS_DENIED`. 네트워크 봉쇄는 **커널 `--allow-net` 거부**(삭제 가능한 JS 하니스가 아님); 기본값 `netEnforcement:"require-permission"`은 강제 못 하는 Node에서 **fail closed**(동작 probe 기능 탐지; PR #54). spawn-storm 서킷 브레이커(PR #56)가 재spawn 제한. lifecycle audit에 호스트 계산/enum 전용 `isolation`/`grants`/`netEnforcement` 추가(PR #56). config: `auth.plugin.isolation:"process"` fail-closed 배선(PR #56). 테스트: fs/net/stdio 레드팀(`--allow-net` 없는 Node에선 fail-closed라 skip) + 상시 실행 fail-closed 계약 + config 매트릭스. **잔여:** `--allow-net` 없는 Node(fail-closed, 미봉쇄); `networkEgress` 부여 plugin; 자식 메모리의 credential/키 자료(core-dump/swap); V8/Node 탈출(런타임 통제일 뿐 OS 샌드박스 아님) |
129
132
  | P1-SEC-028 | 호스트 중개 키 자료 + SSRF: 키 자료가 필요한 커스텀 자격증명 plugin이 plugin 주도 SSRF 벡터가 될 수 있고, 코어엔 SSRF 가드가 없었음(위성 복사본은 코어에서 도달 불가) | Mitigated | 새 node:-only, 의존성 0 **`haechi/ssrf`** 코어 모듈(PR #55): `isBlockedAddress`(private/loopback/link-local/metadata), `guardedFetch`(https 전용, DNS 후 재확인, `redirect:"error"`, 본문 제한 + timeout), `createGuardedKeyFetcher`(TTL 캐시 + cooldown). `process-isolated` 런타임의 선택적 `keyMaterial:{url}`은 **호스트**가 **운영자 선언** URL에서 이 가드로 가져와 IPC로 주입하므로, plugin은 URL을 명명하지 않습니다(plugin 주도 SSRF 없음). kid-refetch cooldown이 아웃바운드 비율을 제한하고, blocked-address URL은 fail closed됩니다. 테스트: 표준 `isBlockedAddress` 벡터 테이블 + 코어-대-`auth-jwt` parity 가드, `guardedFetch` SSRF 거부/제한, cooldown fail-closed, 런타임 키 주입 + no-SSRF. **잔여:** 위성은 의도적으로 로컬 복사본을 유지함(crypto/auth 패키지는 core-ssrf에 런타임 의존 금지; `crypto-kms/ssrf-parity.test.mjs`) — 코어 재import는 연기하며, drift는 제거가 아니라 parity로 가드; 가드의 DNS-rebinding 창(resolve-then-connect)은 운영자 선언 URL에 대해 수용 |
130
133
 
134
+ ## 5.7 2026-06-16 전체 코드리뷰 Open 리스크 상태
135
+
136
+ 권위 있는 항목별 등록부는 `docs/current/code-review-risk-register-2026-06-16.ko.md`입니다. 이 절은 릴리스 게이트 요약입니다. **13개 항목이 모두 Resolved이며 `haechi@1.3.1`로 발행되었습니다**(2026-06-16): P0 + 네 개의 P1(프록시 헤더 경계 패치, SSRF IPv4-mapped 정규화, response-header/streaming 경계, streaming-inspection 텍스트 수정)과 여덟 개의 P2 모두(CR-006 mcp-wrap stderr filter, CR-007 init key-file 검증, CR-008 satellite `manifest.bin` check, CR-009 auth-throw 회귀 테스트, CR-010 process-sandbox quota 테스트, CR-011 audit middle-tamper 테스트, CR-012 vault IPv6 테스트, CR-013 SSE multi-line `data:`). **G9은 Pass입니다.**
137
+
138
+ | ID | 리스크 | 상태 | 종료에 필요한 증거 |
139
+ |---|---|---|---|
140
+ | P0-CR-001 | 프록시가 클라이언트 `Authorization`, `Cookie`, proxy-auth 등 주변 자격증명을 모델 업스트림으로 전달 | Resolved | `filteredHeaders()`의 기본 차단 업스트림 헤더 허용목록 + `createHaechiProxy`에서 전달되는 `forwardPolicy`(게이트웨이 클라이언트 인증과 업스트림 공급자 인증 분리: `auth.provider !== none`이면 클라이언트 `Authorization` 폐기, `none`이면 전달); cookie/proxy-auth/hop-by-hop 항상 폐기; 추가 fail-closed `target.forwardHeaders`; `tests/proxy-header-allowlist.test.mjs`가 게이트웨이 bearer는 업스트림에 안 보이고 공급자 헤더(`x-api-key`/`anthropic-version`/`x-goog-api-key`)는 보임을 증명; README/threat-model/shared-responsibility/configuration(+ko) 갱신 |
141
+ | P1-CR-002 | SSRF 가드가 `::ffff:7f00:1` 같은 hex IPv4-mapped IPv6 private 주소를 놓침 | Resolved | 각 `isBlockedAddress` 복사본(core `packages/ssrf`, `satellites/auth-jwt`, `satellites/crypto-kms/vault.mjs`)이 IPv4-mapped IPv6 주소를 16바이트로 파싱해 임베드된 IPv4(dotted `::ffff:127.0.0.1` 및 hex `::ffff:7f00:1`, bracketed, leading-zero, 혼합 `::`, 대소문자 무시)를 private/loopback/link-local/metadata 검사 전에 정규화; 공인 mapped 주소(`::ffff:8.8.8.8` == `::ffff:808:808`)는 허용 유지되고 기존 vault 과차단도 제거. 복사본은 의도적으로 독립 유지(어떤 위성도 `haechi/ssrf`를 import하지 않음 — core peer floor가 올라감); drift는 parity 테스트로 보증. 테스트: `tests/ssrf.test.mjs`(hex/dotted/bracketed loopback+RFC1918+metadata+public 벡터, core-vs-auth-jwt parity), `satellites/auth-jwt/auth-jwt.test.mjs`(mapped-IPv6 생성 차단 + public-mapped 미차단), `satellites/crypto-kms/vault.test.mjs`(확장된 range table + P2-CR-012 IPv6 loopback 테스트), `satellites/crypto-kms/ssrf-parity.test.mjs`(dotted+hex mapped parity 벡터) |
142
+ | P1-CR-003 | 자동 압축 해제된 업스트림 본문이 기존 압축 응답 헤더와 함께 반환될 수 있음 | Resolved | 중앙화 `sanitizeResponseHeaders()`(content-encoding/content-length/transfer-encoding/hop-by-hop 제거)를 모든 응답 경로(pass-through, 전달/미보호, 보호, streaming)에 적용; 올바른 content-length는 버퍼링된 바디에만 재설정; `tests/proxy-header-allowlist.test.mjs` gzip pass-through + 미보호 응답 테스트가 잔존 content-encoding 없음과 downstream 읽기 가능을 증명 |
143
+ | P1-CR-004 | `streaming.requestMode: "pass-through"`가 response-size cap 없이 전체 업스트림 본문을 버퍼링 | Resolved | 실행 바이트 한도(`responseProtection.maxBytes`)를 가진 진정한 경계 streaming pass-through(`pipeUpstreamBodyBounded`); 초과 시 업스트림 취소 + 클라이언트 쓰기 종료; 미보호/전달 raw read도 한도 적용(초과 시 502); `tests/proxy-header-allowlist.test.mjs`가 oversize pass-through 스트림이 경계/중단됨을 증명 |
144
+ | P1-CR-005 | streaming inspection이 non-JSON SSE/NDJSON 프레임을 원문 통과시켜 plain-text PII 우회 가능 | Resolved | `parseFrame`(`packages/stream-filter/index.mjs`)이 parse 실패 frame을 CONTROL allowlist(`[DONE]`, comment-only, empty/keepalive → 원문 통과)와 non-JSON CONTENT frame(`data:` 텍스트)으로 구분; `handleFrame`이 CONTENT frame을 새 `protector.protectText`(`packages/core/index.mjs`, single-shot `transformSegment`, delta `push`/`flush` 버퍼와 DISTINCT하여 JSON sliding buffer를 오염시키지 않음)로 텍스트 검사하고 `serializeTextFrame`로 `data: <protected text>` 재방출, block action 시 stream fail-closed; response-direction marker skip + audit tally 보존; JSON delta 경로 불변. 테스트: `tests/stream-filter.test.mjs`(plain-text SSE redact, block action 차단, PII 포함 malformed/partial JSON, NDJSON non-JSON 텍스트, control-frame 통과, marker 미재플래그) + `tests/proxy-streaming.test.mjs` end-to-end plain-text 재현 |
145
+ | P2-CR-006 | `mcp-wrap`이 child `stderr`를 filtering/audit 없이 상속 | Resolved | `haechi mcp-wrap`에 `--stderr filter\|drop\|inherit`(기본 `filter`) 추가: 각 완성된 stderr 라인을 재방출 전에 `createStreamProtector().protectText`로 보호(chunk 경계 버퍼링, block-action drop, audit-silent), `drop`은 폐기, `inherit`은 명시적 opt-in 경계, 알 수 없는 값은 fail closed; `tests/mcp-wrap.test.mjs`가 네 가지 모드를 모두 커버 |
146
+ | P2-CR-007 | 기존 key file을 `initLocalKeyFile()`이 검증하지 않음 | Resolved | `initLocalKeyFile`의 기존 파일 non-force 경로가 이제 공유 `loadKeyFile({ requireActive:true })`로 검증(corrupted JSON, active key 부재, 잘못된 길이의 active/retired key 모두 throw); 유효한 파일은 비파괴 유지; `tests/crypto.test.mjs`가 네 가지 케이스를 커버 |
147
+ | P2-CR-008 | satellite packaging check가 `manifest.bin` target file을 검증하지 않음 | Resolved | `evaluateSatellitePackaging()`이 모든 `manifest.bin` 타깃(string + object-map 형식)을 packed-file 집합과 대조해 검증; `tests/satellite-packaging-gate.test.mjs`가 positive + negative(bin 누락) 케이스를 추가 |
148
+ | P2-CR-009 | `authProvider.authenticate()` 예외 경로 회귀 테스트 부재 | Resolved | `tests/proxy-auth.test.mjs`가 throw하는 provider를 주입해 fail-closed(전달 안 됨, generic client error), audit status `haechi_auth_provider_error`, raw error/subject/issuer 미노출을 단언; mutation으로 검증 |
149
+ | P2-CR-010 | process-isolated sandbox quota 분기 parity 테스트 부족 | Resolved | `tests/plugin-process-sandbox.test.mjs`(+ crash fixture)가 isolated-process parity를 추가: oversized result 거부, over-capacity 거부, timeout 종료, child-crash fail-closed; 실제 `process-sandbox.mjs`에 대해 mutation으로 검증 |
150
+ | P2-CR-011 | audit chain 중간 변조 분기 집중 테스트 부족 | Resolved | `tests/audit-chain-tamper.test.mjs`가 실제 multi-record 로그를 기록하고 `verifyAuditChain`이 middle-record content mutation, `previousHash` 누락/오류, 잘못된 `eventHash`를 거부함을 단언; tail-truncation 한계는 계속 문서화 |
151
+ | P2-CR-012 | KMS vault IPv6 loopback carve-out의 IPv6 테스트 부족 | Resolved | `satellites/crypto-kms/vault.test.mjs`에 전용 IPv6 loopback 정책 테스트("…enforces the IPv6 loopback policy (::1, [::1], dotted + hex mapped) — P2-CR-012")를 추가해 bare `::1`, bracketed `[::1]`, dotted `::ffff:127.0.0.1`, hex `::ffff:7f00:1`/`::ffff:7f00:0001`(및 bracketed 변형)을 검증하고, 공인 mapped 주소(`::ffff:8.8.8.8`/`::ffff:808:808`)가 과차단되지 않음을 단언; 확장된 range table과 `ssrf-parity.test.mjs`가 auth-jwt와의 dotted+hex 일치를 고정 |
152
+ | P2-CR-013 | SSE multi-line `data:` 필드를 newline separator 없이 합침 | Resolved | `parseFrame`이 여러 `data:` line을 `join("\n")`(스펙 separator)으로 합치고 line별 스펙 선행 공백 1개만 제거; multi-line JSON은 여전히 `JSON.parse`되고 multi-line plain text는 newline과 함께 재구성되어 검사되며 `serializeTextFrame`가 multi-line payload를 여러 `data:` line으로 재방출; `tests/stream-filter.test.mjs`가 multi-line JSON event와 PII 포함 multi-line plain-text event를 커버 |
153
+
131
154
  ## 6. P2 제품/문서 리스크 상태
132
155
 
133
156
  | ID | 기존 리스크 | 상태 | 해소 증거 |
@@ -141,6 +164,8 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
141
164
 
142
165
  이 체크리스트는 `1.x` stable 라인의 모든 릴리스에 대한 상시 배포 전 템플릿이며, `0.3.2` developer preview에서 처음 적용되었습니다. 그 결과를 아래에 참조 기록으로 보존합니다.
143
166
 
167
+ 2026-06-16 현재 상태: G9은 `Pass`입니다 — 코드리뷰 보완이 `haechi@1.3.1`로 발행되었습니다. 이 체크리스트는 해당 컷에 대해 해제되었습니다.
168
+
144
169
  외부 npm 게이트 확인 결과(`0.3.2` developer preview, 2026-06-10, 배포 후)는 다음과 같습니다.
145
170
 
146
171
  - `npm whoami`: `raeseoklee`