haechi 1.3.0 → 1.3.2

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
 
@@ -140,7 +140,27 @@ HAECHI_BENCH_REQUESTS=5000 HAECHI_BENCH_CONCURRENCY=64 npm run bench:throughput
140
140
  > 네트워크/하드웨어 처리량 벤치마크가 **아니며** 보장 수치로 인용해서는 **안
141
141
  > 됩니다**. 이 벤치는 `release:preflight`에서 실행되지 않습니다.
142
142
 
143
- ## 8. 빠른 참조
143
+ ## 8. 업스트림 검증 (real vLLM / Ollama)
144
+
145
+ `local-inference` 통합 스위트는 요청을 **실제** OpenAI 호환(vLLM) 및/또는
146
+ Ollama 업스트림으로 프록시하여, 프록시가 올바르게 왕복하는지 검증합니다(실제
147
+ 소켓 위에서의 adapter 라우팅 + 요청/응답 보호). 이 스위트는 env-gated되어, 백엔드를
148
+ 가리키지 않으면 **스킵**합니다 — CI는 프로토콜 스텁을 상대로 실행합니다(실제 vLLM은
149
+ GPU가 필요하고 GitHub 호스팅 러너에서 도달할 수 없습니다). 도달 가능한 호스트에서
150
+ 본인의 백엔드를 상대로 검증하려면:
151
+
152
+ ```bash
153
+ HAECHI_VLLM_URL=http://VLLM_HOST:8000 HAECHI_VLLM_MODEL=<served-model> \
154
+ HAECHI_OLLAMA_URL=http://OLLAMA_HOST:11434 HAECHI_OLLAMA_MODEL=<pulled-model> \
155
+ npm run test:inference:live
156
+ ```
157
+
158
+ 보유한 백엔드만 설정하십시오 — 각 테스트는 해당 URL이 설정되지 않으면 스킵합니다.
159
+ 본인의 호스트/IP를 사용하십시오(커밋하지 마십시오). 지속적으로 구동되는 실제 백엔드
160
+ 게이트가 필요하면, 해당 네트워크에 self-hosted 러너를 등록하고 그곳에서 스위트를
161
+ 트리거하십시오. GitHub 호스팅 러너는 사설 LAN에 도달할 수 없습니다.
162
+
163
+ ## 9. 빠른 참조
144
164
 
145
165
  | 작업 | 커맨드 |
146
166
  |---|---|
@@ -225,7 +225,28 @@ default 2000), `HAECHI_BENCH_CONCURRENCY` (default 32), `HAECHI_BENCH_WARMUP`
225
225
  > and must **not** be quoted as guarantees. The bench is not run by
226
226
  > `release:preflight`.
227
227
 
228
- ## 8. Quick reference
228
+ ## 8. Live upstream validation (real vLLM / Ollama)
229
+
230
+ The `local-inference` integration suite proxies a request through to a **real**
231
+ OpenAI-compatible (vLLM) and/or Ollama upstream and asserts the proxy round-trips
232
+ correctly (adapter routing + request/response protection over a real socket). It
233
+ is env-gated, so it **skips** unless you point it at a backend — CI runs it
234
+ against a protocol stub (a real vLLM needs a GPU and is not reachable from a
235
+ GitHub-hosted runner). To validate against your own backend from a host that can
236
+ reach it:
237
+
238
+ ```bash
239
+ HAECHI_VLLM_URL=http://VLLM_HOST:8000 HAECHI_VLLM_MODEL=<served-model> \
240
+ HAECHI_OLLAMA_URL=http://OLLAMA_HOST:11434 HAECHI_OLLAMA_MODEL=<pulled-model> \
241
+ npm run test:inference:live
242
+ ```
243
+
244
+ Set only the backend(s) you have — each test skips when its URL is unset. Use
245
+ your own host/IP (do not commit it). For a continuously-exercised real-backend
246
+ gate, register a self-hosted runner on that network and trigger the suite there;
247
+ GitHub-hosted runners cannot reach a private LAN.
248
+
249
+ ## 9. Quick reference
229
250
 
230
251
  | Task | Command |
231
252
  |---|---|
@@ -27,7 +27,9 @@ npm run release:preflight:npm
27
27
 
28
28
  1. ✅ npmjs.com에서: package settings → Trusted Publisher → `raeseoklee/haechi` 저장소와 `npm-publish.yml` workflow 연결 (2026-06-10).
29
29
  2. ✅ `.github/workflows/npm-publish.yml` OIDC 인증 전환 (2026-06-10): `NODE_AUTH_TOKEN`과 `registry-url` 제거, runner의 npm CLI를 `>= 11.5.1`로 업그레이드.
30
- 3. ✅ `haechi@0.4.0`으로 검증 완료 (2026-06-10): `npm view haechi --json`에서 SLSA provenance v1 predicate를 가진 `dist.attestations` 확인. 로컬 패스키로 배포한 `haechi@0.3.2`만 비증명 상태로 남습니다.
30
+ 3. ✅ `haechi@0.4.0`으로 검증 완료 (2026-06-10): `npm view haechi --json`에서 SLSA provenance v1 predicate를 가진 `dist.attestations` 확인.
31
+
32
+ **비증명 버전(로컬 패스키 첫 발행):** `haechi@0.3.2`와 `haechi-ratelimit-redis@0.1.0`(2026-06-16)은 각각 로컬 머신에서 `--provenance=false`로 배포되어 두 버전의 provenance 증명이 존재하지 않습니다 — 둘 다 아직 존재하지 않던 패키지의 **이름을 확보하는 첫 발행**이었기 때문입니다(Trusted Publisher가 완전히 새로운 이름을 부트스트랩할 수 없는 이유는 §5 참조). 각 패키지의 이후 모든 버전은 OIDC workflow로 증명됩니다.
31
33
 
32
34
  provenance 없이 수행한 publish는 release note에 갭을 명시적으로 기록해야 합니다(`CONTRIBUTING.md` 참조).
33
35
 
@@ -80,10 +82,16 @@ Satellite는 npm workspaces 모노레포의 `satellites/*`에 살며 core와 **
80
82
 
81
83
  **satellite별 부트스트랩 순서(첫 발행, org 불필요):**
82
84
 
83
- 1. npmjs.com에서 (아직 미발행) unscoped 이름(예: `haechi-crypto-kms`)에 **Trusted Publisher 설정**: `raeseoklee/haechi` 저장소와 satellite의 **정확한 워크플로 파일명**(예: `crypto-kms-publish.yml`)을 연결합니다. npm은 아직 발행 전인 이름에도 Trusted Publisher 설정을 허용합니다.
84
- 2. 접두사 태그를 push하고 GitHub Release를 발행하면(예: `crypto-kms-v0.1.0`) 워크플로의 OIDC publish가 provenance와 함께 `0.1.0`을 생성하고 첫 발행 시 이름을 확보합니다.
85
+ 아직 존재하지 않는 이름에는 Trusted Publisher를 설정할 **수 없습니다** — npm은 **이미 존재하는** 패키지의 설정 페이지에서만 Trusted Publisher 설정을 노출합니다. 따라서 완전히 새로운 unscoped 이름은 단계 부트스트랩을 거칩니다: 먼저 수동 발행으로 이름을 *생성하고 확보*한 뒤, Trusted Publisher 설정하여 이후 모든 버전이 OIDC로 증명되게 합니다.
85
86
 
86
- 노트북에서의 수동 `npm publish`는 필요 없습니다. 이름이 unscoped이고 비어있으므로 org-membership 선행 요건이 없습니다.
87
+ 1. **수동 발행(이름 확보; 로컬, provenance 없음).** satellite 디렉터리에서, 패스키/WebAuthn 계정이 터미널 OTP 없이 인증되도록 브라우저로 인증한 뒤 provenance를 끄고 발행합니다(로컬 머신에는 OIDC id-token이 없어 증명할 수 없습니다).
88
+ ```bash
89
+ npm login --auth-type=web
90
+ cd satellites/<name> && npm publish --auth-type=web --provenance=false
91
+ ```
92
+ 각 satellite `package.json`의 `publishConfig.access: "public"`이 unscoped 패키지를 public으로 만듭니다. 이 첫 버전은 **비증명**입니다 — §2 / `CONTRIBUTING.md`에 따라 갭을 기록하세요.
93
+ 2. **이제 패키지가 존재하므로 → Trusted Publisher 설정**: npmjs.com에서 package settings → Trusted Publisher → `raeseoklee/haechi` 저장소와 satellite의 **정확한 워크플로 파일명**(예: `crypto-kms-publish.yml`)을 연결합니다.
94
+ 3. **이후 모든 버전은 OIDC로 증명됩니다.** satellite `package.json`을 bump하고, 접두사 태그를 push한 뒤, GitHub Release를 발행하면(예: `crypto-kms-v0.1.1`) 워크플로의 OIDC publish가 provenance와 함께 해당 버전을 발행합니다. 이 시점부터는 노트북도 OTP도 필요 없습니다. 이름이 unscoped이고 비어있으므로 org-membership 선행 요건이 없습니다.
87
95
 
88
96
  **태그 → 워크플로 → 패키지 매핑:**
89
97
 
@@ -104,9 +112,9 @@ npm view haechi-crypto-kms --json # dist.attestations 존재 확인; access "p
104
112
 
105
113
  **의존성 노트:** `haechi-crypto-kms`는 core를 zero-dependency로 유지합니다 — `@aws-sdk/client-kms`는 **optional peer dependency**이며, 실제 AWS 클라이언트를 쓰고 주입하지 않을 때만 lazy import됩니다. in-memory 또는 주입형 클라이언트를 쓰는 소비자는 SDK를 설치하지 않습니다. 0.2.0의 `./gcp`(`@google-cloud/kms`)와 `./azure`(`@azure/keyvault-keys` + `@azure/identity`) 백엔드도 동일한 optional-peer/lazy-import 모델을 따르며, `./vault` 백엔드는 optional peer가 없습니다(`node:` `fetch` 전용).
106
114
 
107
- **0.9 satellite(새 unscoped 이름 — 첫 태그 *전에* Trusted Publisher 설정):** `haechi-dashboard`와 `haechi-auth-oidc`는 0.9에서 첫 발행되며 위의 satellite별 부트스트랩 순서를 동일하게 따릅니다. 0.8 satellite와 마찬가지로 unscoped 이름은 OIDC publish 확보되므로, 각각의 npmjs.com Trusted Publisher를 태그 **전에** 설정해야 합니다 — `raeseoklee/haechi` 저장소와 정확한 워크플로 파일명(`haechi-dashboard`는 `dashboard-publish.yml`, `haechi-auth-oidc`는 `auth-oidc-publish.yml`) 연결한 뒤, 접두사 태그(`dashboard-v0.1.0`, `auth-oidc-v0.1.0`)를 push하고 GitHub Release를 발행합니다. 기존 두 satellite는 이미 부트스트랩된 태그/워크플로를 그대로 사용합니다: `haechi-auth-jwt@0.2.0`은 `auth-jwt-v<semver>`(`auth-jwt-publish.yml`), `haechi-crypto-kms@0.2.0`은 `crypto-kms-v<semver>`(`crypto-kms-publish.yml`) — 이 둘은 새 Trusted Publisher 설정이 필요 없습니다.
115
+ **0.9 satellite(새 unscoped 이름):** `haechi-dashboard`와 `haechi-auth-oidc`는 0.9에서 위의 단계 부트스트랩으로 발행되었습니다 수동발행으로 이름을 확보한 Trusted Publisher를 설정했고, 이후 태그 릴리스(`dashboard-v<semver>`, `auth-oidc-v<semver>`) OIDC로 발행됩니다. 0.8 satellite 개는 이미 존재하므로 이미 부트스트랩된 태그/워크플로를 그대로 사용합니다: `haechi-auth-jwt`는 `auth-jwt-v<semver>`(`auth-jwt-publish.yml`), `haechi-crypto-kms`는 `crypto-kms-v<semver>`(`crypto-kms-publish.yml`) — 이 둘은 새 Trusted Publisher 설정이 필요 없습니다.
108
116
 
109
- **`haechi-ratelimit-redis`( unscoped 이름 — 첫 태그 *전에* Trusted Publisher 설정):** 공유 저장소 rate-limiter satellite는 고유의 `ratelimit-redis-v<semver>` 태그에서 첫 발행되며 위의 satellite별 부트스트랩 순서를 동일하게 따릅니다. unscoped 이름은 OIDC publish 시 확보되므로, npmjs.com Trusted Publisher를 태그 **전에** 설정해야 합니다 `raeseoklee/haechi` 저장소와 정확한 워크플로 파일명 `ratelimit-redis-publish.yml`을 연결한 뒤, 접두사 태그(`ratelimit-redis-v0.1.0`)를 push하고 GitHub Release를 발행합니다. `redis` 클라이언트는 **optional peer dependency**이며 번들된 Redis 어댑터를 쓰는 소비자만 import합니다(store/client는 주입됩니다). 따라서 core는 zero-dependency로 유지됩니다.
117
+ **`haechi-ratelimit-redis`(부트스트랩 2026-06-16):** 공유 저장소 rate-limiter satellite는 위의 단계 부트스트랩을 따랐습니다. `0.1.0`은 이름을 확보한 **수동발행**(로컬 패스키 web 인증, `--provenance=false`)이므로 **비증명**입니다(§2에 기록). 이후 Trusted Publisher(`ratelimit-redis-publish.yml`)를 설정했고, `0.1.1`부터의 모든 버전은 `ratelimit-redis-v<semver>` 태그 워크플로로 provenance와 함께 발행됩니다. `redis` 클라이언트는 **optional peer dependency**이며 번들된 Redis 어댑터를 쓰는 소비자만 import합니다(store/client는 주입됩니다). 따라서 core는 zero-dependency로 유지됩니다.
110
118
 
111
119
  ## 6. 배포 차단 조건
112
120
 
@@ -27,7 +27,9 @@ The intended publish path is GitHub Actions trusted publishing: npm authenticate
27
27
 
28
28
  1. ✅ On npmjs.com: package settings → Trusted Publisher → linked the `raeseoklee/haechi` repository and the `npm-publish.yml` workflow (2026-06-10).
29
29
  2. ✅ `.github/workflows/npm-publish.yml` authenticates via OIDC (2026-06-10): `NODE_AUTH_TOKEN` and `registry-url` removed, npm CLI upgraded to `>= 11.5.1` in the runner.
30
- 3. ✅ Verified with `haechi@0.4.0` (2026-06-10): `npm view haechi --json` shows `dist.attestations` with a SLSA provenance v1 predicate. Only `haechi@0.3.2` remains unattested (published via local passkey).
30
+ 3. ✅ Verified with `haechi@0.4.0` (2026-06-10): `npm view haechi --json` shows `dist.attestations` with a SLSA provenance v1 predicate.
31
+
32
+ **Unattested versions (local passkey first publishes):** `haechi@0.3.2` and `haechi-ratelimit-redis@0.1.0` (2026-06-16) were each published from a local machine with `--provenance=false`, so no provenance attestation exists for those two versions — both were the **name-claiming first publish** of a package that did not yet exist (see §5 on why a Trusted Publisher cannot bootstrap a brand-new name). Every later version of each package is attested via the OIDC workflow.
31
33
 
32
34
  Any publish performed without provenance must record the gap explicitly in the release notes (see `CONTRIBUTING.md`).
33
35
 
@@ -80,10 +82,16 @@ Satellites live under `satellites/*` in the npm workspaces monorepo and publish
80
82
 
81
83
  **Per-satellite bootstrap order (first publish, no org needed):**
82
84
 
83
- 1. On npmjs.com, **configure a Trusted Publisher** for the (not-yet-published) unscoped name (e.g. `haechi-crypto-kms`): link the `raeseoklee/haechi` repository and the satellite's **exact workflow filename** (e.g. `crypto-kms-publish.yml`). npm allows configuring a Trusted Publisher for a name you have not published yet.
84
- 2. Push the prefixed tag and publish a GitHub Release (e.g. `crypto-kms-v0.1.0`) → the workflow's OIDC publish creates `0.1.0` with provenance and claims the name on first publish.
85
+ A Trusted Publisher **cannot** be configured for a name that does not exist yet npm only exposes the Trusted Publisher setting on an **existing** package's settings page. So a brand-new unscoped name has a two-phase bootstrap: a manual first publish to *create and claim* the name, then Trusted-Publisher configuration so every later version is OIDC-attested.
85
86
 
86
- No manual `npm publish` from a laptop is needed. Because the names are unscoped and free, there is no org-membership prerequisite.
87
+ 1. **Manual first publish (claims the name; local, no provenance).** From the satellite directory, authenticate via the browser so a passkey/WebAuthn account needs no terminal OTP, then publish with provenance off (a local machine has no OIDC id-token, so it cannot attest):
88
+ ```bash
89
+ npm login --auth-type=web
90
+ cd satellites/<name> && npm publish --auth-type=web --provenance=false
91
+ ```
92
+ `publishConfig.access: "public"` in each satellite's `package.json` makes the unscoped package public. This first version is **unattested** — record the gap per §2 / `CONTRIBUTING.md`.
93
+ 2. **Now the package exists → configure a Trusted Publisher** on npmjs.com: package settings → Trusted Publisher → link the `raeseoklee/haechi` repository and the satellite's **exact workflow filename** (e.g. `crypto-kms-publish.yml`).
94
+ 3. **Every subsequent version is OIDC-attested.** Bump the satellite `package.json`, push the prefixed tag, and publish a GitHub Release (e.g. `crypto-kms-v0.1.1`) → the workflow's OIDC publish ships that version with provenance. No laptop and no OTP from here on. Because the names are unscoped and free, there is no org-membership prerequisite.
87
95
 
88
96
  **Tag → workflow → package mapping:**
89
97
 
@@ -104,9 +112,9 @@ npm view haechi-crypto-kms --json # dist.attestations present; access "public"
104
112
 
105
113
  **Dependency note:** `haechi-crypto-kms` keeps core zero-dependency — `@aws-sdk/client-kms` is an **optional peer dependency**, imported lazily only when a real AWS client is used and not injected. Consumers who use the in-memory or an injected client never install the SDK. The 0.2.0 `./gcp` (`@google-cloud/kms`) and `./azure` (`@azure/keyvault-keys` + `@azure/identity`) backends follow the same optional-peer/lazy-import model; the `./vault` backend has zero optional peer (`node:` `fetch` only).
106
114
 
107
- **0.9 satellites (new unscoped names — configure Trusted Publisher *before* the first tag):** `haechi-dashboard` and `haechi-auth-oidc` are first-published in 0.9 and follow the same per-satellite bootstrap order above. As with the 0.8 satellites, the unscoped name is claimed on first OIDC publish, so the npmjs.com Trusted Publisher for each must be configured **before** its first tag — link `raeseoklee/haechi` and the exact workflow filename (`dashboard-publish.yml` for `haechi-dashboard`, `auth-oidc-publish.yml` for `haechi-auth-oidc`), then push the prefixed tag (`dashboard-v0.1.0`, `auth-oidc-v0.1.0`) and publish the GitHub Release. The two existing satellites ride their already-bootstrapped tags/workflows: `haechi-auth-jwt@0.2.0` on `auth-jwt-v<semver>` (`auth-jwt-publish.yml`) and `haechi-crypto-kms@0.2.0` on `crypto-kms-v<semver>` (`crypto-kms-publish.yml`) — no new Trusted Publisher configuration is required for those two.
115
+ **0.9 satellites (new unscoped names):** `haechi-dashboard` and `haechi-auth-oidc` were first-published in 0.9 via the two-phase bootstrap above a manual first publish to claim each name, then the Trusted Publisher, after which their tagged releases (`dashboard-v<semver>`, `auth-oidc-v<semver>`) publish via OIDC. The two 0.8 satellites already exist and ride their already-bootstrapped tags/workflows: `haechi-auth-jwt` on `auth-jwt-v<semver>` (`auth-jwt-publish.yml`) and `haechi-crypto-kms` on `crypto-kms-v<semver>` (`crypto-kms-publish.yml`) — no new Trusted Publisher configuration is required for those two.
108
116
 
109
- **`haechi-ratelimit-redis` (new unscoped name — configure Trusted Publisher *before* the first tag):** the shared-store rate-limiter satellite is first-published from its own `ratelimit-redis-v<semver>` tag and follows the same per-satellite bootstrap order above. The unscoped name is claimed on its first OIDC publish, so its npmjs.com Trusted Publisher must be configured **before** its first tag link `raeseoklee/haechi` and the exact workflow filename `ratelimit-redis-publish.yml`, then push the prefixed tag (`ratelimit-redis-v0.1.0`) and publish the GitHub Release. The `redis` client is an **optional peer dependency**, imported only by consumers using the bundled Redis adapter (the store/client is injected), so core stays zero-dependency.
117
+ **`haechi-ratelimit-redis` (bootstrapped 2026-06-16):** the shared-store rate-limiter satellite followed the two-phase bootstrap above. `0.1.0` was the **manual first publish** (local passkey web auth, `--provenance=false`) that claimed the name so it is **unattested** (recorded in §2). The Trusted Publisher (`ratelimit-redis-publish.yml`) was then configured, and every version from `0.1.1` on is published via the `ratelimit-redis-v<semver>` tag → workflow with provenance. The `redis` client is an **optional peer dependency**, imported only by consumers using the bundled Redis adapter (the store/client is injected), so core stays zero-dependency.
110
118
 
111
119
  ## 6. Deployment block conditions
112
120