haechi 1.3.1 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/docs/current/code-review-risk-register-2026-06-16-round2.ko.md +142 -0
- package/docs/current/code-review-risk-register-2026-06-16-round2.md +142 -0
- package/docs/current/operations-runbook.ko.md +32 -1
- package/docs/current/operations-runbook.md +39 -1
- package/docs/current/release-process.ko.md +15 -6
- package/docs/current/release-process.md +15 -6
- package/docs/current/reliability-hardening-track.ko.md +1 -1
- package/docs/current/reliability-hardening-track.md +1 -1
- package/docs/current/risk-register-release-gate.ko.md +22 -4
- package/docs/current/risk-register-release-gate.md +22 -4
- package/package.json +2 -1
- package/packages/cli/bin/haechi.mjs +1 -1
- package/packages/cli/runtime.mjs +5 -1
- package/packages/filter/index.mjs +155 -7
- package/packages/plugin/process-sandbox.mjs +56 -1
- package/packages/plugin/sandbox.mjs +23 -0
- package/packages/proxy/index.mjs +128 -12
- package/packages/token-vault/index.mjs +46 -5
package/README.ko.md
CHANGED
|
@@ -90,7 +90,7 @@ node packages/cli/bin/haechi.mjs proxy --config haechi.config.json
|
|
|
90
90
|
|
|
91
91
|
proxy는 기본적으로 loopback에 바인딩됩니다. `0.0.0.0`, `::`, 또는 그 밖의 non-loopback 호스트에 바인딩하려면 `--allow-remote-bind`를 명시적으로 전달해야 합니다. 이 플래그는 명시적인 네트워크 접근 통제가 있을 때만 사용하세요.
|
|
92
92
|
|
|
93
|
-
`stream: true`인 스트리밍 요청은 기본적으로 차단됩니다. `streaming.requestMode`를 `inspect`로 설정하면 SSE/NDJSON 응답을 stream-filter합니다(bounded sliding buffer가 프레임에 걸쳐 나뉜 PII도
|
|
93
|
+
`stream: true`인 스트리밍 요청은 기본적으로 차단됩니다. `streaming.requestMode`를 `inspect`로 설정하면 SSE/NDJSON 응답을 stream-filter합니다(JSON **델타 채널**을 훑는 bounded sliding buffer가 델타 프레임에 걸쳐 나뉜 PII도 `streaming.maxMatchBytes`까지 잡아냅니다. 델타가 아닌 leaf와 비JSON 프레임은 각 프레임 내에서 검사됩니다). 호출자가 보호되지 않는 스트리밍을 명시적으로 감수하는 경우에만 `pass-through`로 설정하세요.
|
|
94
94
|
|
|
95
95
|
Ollama의 `/api/chat`과 `/api/generate`는 `stream` 필드가 없으면 기본적으로 스트리밍하므로, proxy는 `stream: false`가 명시되지 않는 한 이 요청들을 스트리밍으로 간주합니다.
|
|
96
96
|
|
|
@@ -153,7 +153,7 @@ stdio MCP 서버를 감싸 양방향 트래픽을 필터링합니다. MCP 클라
|
|
|
153
153
|
}
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
클라이언트→서버 요청은 `mcp.allowedMethods` allowlist와 params 보호를 거치고, 서버→클라이언트 결과는 params/result 보호와 injection 휴리스틱(아래 참고)을 적용받습니다. 거부된 요청은 클라이언트에 응답되고 서버에는 도달하지 않습니다.
|
|
156
|
+
클라이언트→서버 요청은 `mcp.allowedMethods` allowlist와 params 보호를 거치고, 서버→클라이언트 결과는 params/result 보호와 injection 휴리스틱(아래 참고)을 적용받습니다. 거부된 요청은 클라이언트에 응답되고 서버에는 도달하지 않습니다. exit code는 그대로 전달되며, 자식의 stderr은 기본적으로 줄 단위로 동일한 보호를 거쳐 **필터링됩니다**(`--stderr filter`) — 원시 전달은 `--stderr inherit`을, 폐기는 `--stderr drop`을 사용하세요(줄 단위 필터는 자식이 개행을 넘어 쪼갠 비밀을 잡지 못하므로, 고민감 도구에는 폐기를 권장합니다). `filter`는 `policy.mode: enforce`에서만 변환합니다.
|
|
157
157
|
|
|
158
158
|
## Injection Detection (Preview)
|
|
159
159
|
|
|
@@ -322,7 +322,7 @@ Haechi는 로컬 정책 부트스트래핑을 위한 지역별 기본 Privacy Pr
|
|
|
322
322
|
|
|
323
323
|
0.4.0은 token round-trip(deterministic tokenization + 요청 스코프 응답 detokenization), `mcp-wrap` 양방향 MCP 필터, `status` 및 `audit-verify` 커맨드, report-only injection detection 휴리스틱을 추가하고, 0.6 인증을 위한 PII-safe `identity`/`authProvider` 계약을 예약합니다. `docs/current/release-0.4-implementation-scope.md` 참고.
|
|
324
324
|
|
|
325
|
-
0.5.0은 SSE/NDJSON 스트리밍 응답 검사를 추가합니다. `streaming.requestMode: "inspect"`가 bounded sliding buffer로 응답을 stream-filter하여 프레임에 걸쳐 나뉜 PII도 잡아냅니다(`streaming.maxMatchBytes`). `docs/current/release-0.5-implementation-scope.md` 참고.
|
|
325
|
+
0.5.0은 SSE/NDJSON 스트리밍 응답 검사를 추가합니다. `streaming.requestMode: "inspect"`가 JSON **델타 채널**을 훑는 bounded sliding buffer로 응답을 stream-filter하여 델타 프레임에 걸쳐 나뉜 PII도 잡아냅니다(`streaming.maxMatchBytes`). 델타가 아닌 leaf와 비JSON 콘텐츠 프레임은 각 프레임 내에서 검사됩니다. `docs/current/release-0.5-implementation-scope.md` 참고.
|
|
326
326
|
|
|
327
327
|
0.6.0은 인증과 클라이언트별 통제를 추가합니다. 해시 기반 token 저장소와 `haechi auth` CLI를 갖춘 내장 bearer auth, identity scope/label로 바인딩되는 named policy profile, model allowlisting, identity별 rate limiting을 제공하며, audit 로그에는 PII-safe identity가 기록됩니다. `docs/current/release-0.6-implementation-scope.md` 참고.
|
|
328
328
|
|
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ Point an existing HTTP JSON client at `http://localhost:11016` and set `target.u
|
|
|
90
90
|
|
|
91
91
|
The proxy binds to loopback by default. Binding to `0.0.0.0`, `::`, or another non-loopback host fails unless `--allow-remote-bind` is provided. Use that flag only behind explicit network access controls.
|
|
92
92
|
|
|
93
|
-
Streaming requests with `stream: true` are blocked by default. Set `streaming.requestMode` to `inspect` to stream-filter SSE/NDJSON responses (a bounded sliding buffer catches PII split across frames
|
|
93
|
+
Streaming requests with `stream: true` are blocked by default. Set `streaming.requestMode` to `inspect` to stream-filter SSE/NDJSON responses (a bounded sliding buffer over the JSON **delta channel** catches PII split across delta frames, up to `streaming.maxMatchBytes`; non-delta leaves and non-JSON frames are inspected within each frame), or to `pass-through` only when the caller explicitly accepts unprotected streaming.
|
|
94
94
|
|
|
95
95
|
Ollama `/api/chat` and `/api/generate` stream by default when the `stream` field is omitted, so the proxy treats those requests as streaming unless `stream: false` is explicitly set.
|
|
96
96
|
|
|
@@ -153,7 +153,7 @@ Wrap any stdio MCP server so its traffic is filtered in both directions — chan
|
|
|
153
153
|
}
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
Client→server requests pass the `mcp.allowedMethods` allowlist and params protection; server→client results get params/result protection plus injection heuristics (see below). Rejections are answered to the client and never reach the server; stderr
|
|
156
|
+
Client→server requests pass the `mcp.allowedMethods` allowlist and params protection; server→client results get params/result protection plus injection heuristics (see below). Rejections are answered to the client and never reach the server. Exit codes pass through; the child's stderr is **filtered** through the same protection per line by default (`--stderr filter`) — use `--stderr inherit` for raw passthrough or `--stderr drop` to discard (recommended for high-sensitivity tools, since a per-line filter cannot catch a secret a child splits across a newline). `filter` transforms only under `policy.mode: enforce`.
|
|
157
157
|
|
|
158
158
|
## Injection Detection (Preview)
|
|
159
159
|
|
|
@@ -322,7 +322,7 @@ Set `privacy.profile` in `haechi.config.json` to apply the profile's default act
|
|
|
322
322
|
|
|
323
323
|
0.4.0 adds the token round-trip (deterministic tokenization + request-scoped response detokenization), the `mcp-wrap` bidirectional MCP filter, `status` and `audit-verify` commands, report-only injection detection heuristics, and reserves the PII-safe `identity`/`authProvider` contracts for 0.6 auth. See `docs/current/release-0.4-implementation-scope.md`.
|
|
324
324
|
|
|
325
|
-
0.5.0 adds SSE/NDJSON streaming response inspection: `streaming.requestMode: "inspect"` stream-filters responses with a bounded sliding buffer that catches PII split across frames (`streaming.maxMatchBytes`). See `docs/current/release-0.5-implementation-scope.md`.
|
|
325
|
+
0.5.0 adds SSE/NDJSON streaming response inspection: `streaming.requestMode: "inspect"` stream-filters responses with a bounded sliding buffer over the JSON **delta channel** that catches PII split across delta frames (`streaming.maxMatchBytes`); non-delta leaves and non-JSON content frames are inspected within each frame. See `docs/current/release-0.5-implementation-scope.md`.
|
|
326
326
|
|
|
327
327
|
0.6.0 adds authentication and per-client controls: built-in bearer auth with a hashed token store and `haechi auth` CLI, named policy profiles bound by identity scope/label, model allowlisting, and per-identity rate limiting — with PII-safe identity in the audit log. See `docs/current/release-0.6-implementation-scope.md`.
|
|
328
328
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# 2026-06-16 코드리뷰 리스크 등록부 — Round 2 (CR2)
|
|
2
|
+
|
|
3
|
+
상태: 보완 완료 및 `haechi@1.3.2`로 발행(P0/P1 없음; CR2-001..008 Resolved; CR2-009 won't-fix, CR2-010 accepted; G10 Pass, 2026-06-16)
|
|
4
|
+
범위: `main`의 `36af9fd1eef2b1e19b19b2e0344faab0a7a3e83d`(post-1.3.1)
|
|
5
|
+
검토일: 2026-06-16
|
|
6
|
+
출처: 1.3.1 보완 컷 이후 진행한 2차 심층 리뷰, 각 항목을 현재 코드에 대해 적대적으로 검증
|
|
7
|
+
|
|
8
|
+
이 문서는 `code-review-risk-register-2026-06-16.md`(round 1, 모두 Resolved이며 1.3.1로 발행)와 분리해 둔 **2차 라운드**다. round 1은 threat model과 코드가 인용하는 frozen resolution 기록이다. Round 2는 1.3.1 이후 제기되어 현재 트리에 대해 재검증된 항목을 담는다. 일부는 round-1 주장을 확장하거나 한정하므로, frozen 기록에 되쓰지 않고 상호 참조한다.
|
|
9
|
+
|
|
10
|
+
## 릴리스 판단
|
|
11
|
+
|
|
12
|
+
round-1 P0/P1은 모두 Resolved이며 `haechi@1.3.1`로 발행되었다. Round 2는 **P0도 P1도 발견하지 못했다**: 외부 리뷰에서 P1로 제기된 두 항목은 모두 검증 결과 **P2**로 내려갔다(둘 다 stored-plaintext leak도, auth/SSRF 우회도 아니다). 확인된 P2는 프록시 데이터 경로의 availability/resource leak, 호출자 제공 입력을 반영하는 audit-hygiene 공백, 그리고 무제한 plugin IPC reply다. 세 개의 P2와 P3 묶음(`CR2-001..008`)은 이제 **Resolved이며 `haechi@1.3.2`로 발행되었다**(2026-06-16, attested OIDC publish); `CR2-009`는 won't-fix(false positive), `CR2-010`은 accepted(문서화된 잔여 리스크)로 유지된다. **G10은 Pass다.** 운영자는 CR2 수정 사항을 반영하려면 `1.3.1`에서 `1.3.2`로 업그레이드해야 한다.
|
|
13
|
+
|
|
14
|
+
## 심각도 기준
|
|
15
|
+
|
|
16
|
+
- `P0`: 신뢰 경계를 넘어가는 직접적인 자격증명/데이터 유출, 또는 핵심 보안 약속을 깨는 우회.
|
|
17
|
+
- `P1`: SSRF, 보호 우회, 서비스 거부, 보호 배포를 깨뜨릴 수 있는 프로토콜 동작.
|
|
18
|
+
- `P2`: 넓은 채택 전 해결해야 하는 운영, 정확성, availability, hygiene 공백.
|
|
19
|
+
- `P3`: 영향이 작은 하드닝, 유한 경계 robustness, 또는 문서 정확성.
|
|
20
|
+
|
|
21
|
+
## 검증 노트
|
|
22
|
+
|
|
23
|
+
아래 모든 항목은 독립 리뷰어가 보고자 진술을 그대로 믿지 않고 현재 코드에 대해 추적했다. 보고된 P1 두 건은 검증 후 P2로 하향됐다(부풀림 없음). 보고된 한 항목은 **false positive**였고 한 항목은 **이미 문서화된 수용 잔여 리스크**였다 — 둘 다 audit trail을 위해 여기 기록하며 코드 변경은 필요 없다.
|
|
24
|
+
|
|
25
|
+
## 요약
|
|
26
|
+
|
|
27
|
+
| ID | 심각도 | 영역 | 리스크 | 상태 |
|
|
28
|
+
| --- | --- | --- | --- | --- |
|
|
29
|
+
| CR2-001 | P2 | 프록시 availability | pass-through streaming이 downstream 클라이언트 disconnect 시 upstream reader를 절대 취소하지 않는다 — `await once(response,"drain")`이 영원히 park되어 upstream connection/task가 leak되고, 인증되지 않은 클라이언트가 반복적으로 disconnect해 dangling upstream connection을 누적할 수 있다. | Resolved |
|
|
30
|
+
| CR2-002 | P2 | audit hygiene | token-vault reveal/purge 실패가 호출자 제공 raw `token`과 `error.message`(token을 interpolate함)를 audit event에 기록한다; `FORBIDDEN_KEYS`는 key 이름으로만 제거하므로, `tok_` id가 기대되는 자리에 secret을 넘기면 hash-chained 로그에 raw로 남는다. stored vault plaintext가 아니라 호출자 입력을 반영한다. | Resolved |
|
|
31
|
+
| CR2-003 | P2 | plugin sandbox DoS | `maxMessageBytes`는 host→plugin credential 메시지만 제한한다; plugin→host reply는 무제한으로 수신·`JSON.parse`된다. process-isolated child에 heap cap이 없어, 적대적/버그 있는 signed plugin이 oversized reply를 반환 → host의 동기 parse가 event loop를 정지시키고 메모리가 급증한다. | Resolved |
|
|
32
|
+
| CR2-004 | P3 | 프록시 헤더 | `sanitizeResponseHeaders`가 body가 변환됐을 때 body-coupled validator(`etag`/`content-md5`/`digest`/`last-modified`)를 유지해 stale 상태가 된다; 변경된 응답에 `cache-control: no-store`가 없다. | Resolved |
|
|
33
|
+
| CR2-005 | P3 | 프록시 robustness | `maxBytes`를 초과하는 body에 대해 `readBody`는 reject하지만 socket 읽기/teardown을 멈추지 않아, 업로드가 (유한한) Node `requestTimeout`까지 read-and-discard된다. | Resolved |
|
|
34
|
+
| CR2-006 | P3 | MCP wrap | `mcp-wrap --stderr filter`는 완성된 라인 단위로 보호하므로, 적대적 child가 의도적으로 newline에 걸쳐 분할한 secret은 anchored regex를 회피한다. 신뢰된 로컬 child의 진단 출력에 대한 라인 지향 필터링의 본질적 한계다; single-line secret은 잡히고 `--stderr drop`이 있다. 문서 전용. | Resolved |
|
|
35
|
+
| CR2-007 | P3 | Docs | README는 MCP wrap이 "stderr and exit codes pass through"라고 하지만, 기본값은 이제 `--stderr filter`다(round-1 P2-CR-006). | Resolved |
|
|
36
|
+
| CR2-008 | P3 | Docs | README의 streaming "split match" 주장이 범위 한정이 없다; cross-frame buffering은 JSON delta 채널에만 적용되며 임의의 non-JSON frame에는 적용되지 않는다. | Resolved |
|
|
37
|
+
| CR2-009 | — | plugin sandbox | (보고된 P2) `keyMaterial`이 base credential 메시지의 `maxMessageBytes` 검사 이후에 append된다. **FALSE POSITIVE:** `keyMaterial`은 운영자 통제이며 fetcher의 `maxBytes`로 hard-bound된다; 공격자 증폭 없음. 수정 불필요(선택적 cosmetic re-assert만). | Won't fix |
|
|
38
|
+
| CR2-010 | — | Streaming | (보고된 P2) 두 개의 NON-JSON SSE/NDJSON frame에 걸쳐 분할된 secret은 잡히지 않는다(per-frame 검사). **수용 잔여 리스크 — 이미 문서화됨**: round-1 P1-CR-005 resolution, `threat-model.md` exclusions, 그리고 in-code comment. 변경 없음. | Accepted |
|
|
39
|
+
|
|
40
|
+
## 상세 항목
|
|
41
|
+
|
|
42
|
+
### CR2-001: downstream disconnect 시 upstream reader 미취소
|
|
43
|
+
|
|
44
|
+
심각도: P2(가장 시급한 CR2 항목)
|
|
45
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #96)
|
|
46
|
+
영향 코드: `packages/proxy/index.mjs`의 `pipeUpstreamBodyBounded` / `forward`
|
|
47
|
+
검증: pass-through streaming 경로에는 클라이언트 연결의 `close`/`aborted` listener가 없다; 클라이언트 socket이 죽은 뒤 `await once(response, "drain")`이 무기한 park된다(`drain`도 `error`도 발생하지 않음). 그래서 async task와 upstream connection이 leak된다. 전제 조건 없이 **인증되지 않은** 클라이언트가 도달 가능하다; 스트림 도중 반복 disconnect는 프록시와 그 upstream LLM 엔드포인트에 대한 dangling upstream connection을 누적한다.
|
|
48
|
+
|
|
49
|
+
해소: per-request `AbortController`를 `forward()`에 전달하고(upstream fetch를 abort) upstream reader를 취소하는 one-shot 클라이언트 `close`/`aborted` listener를 등록한다; `drain` 대기를 `close`와 race시켜 backpressure 대기가 disconnect 시 unpark되게 한다; no-backpressure `reader.read()` parked 케이스도 다룬다. 회귀 테스트: 스트림 도중 disconnect하고 reader가 즉시 취소되는지 / upstream이 abort되는지 단언.
|
|
50
|
+
|
|
51
|
+
### CR2-002: Token-Vault Reveal/Purge가 Raw Token + Error Text를 Audit에 기록
|
|
52
|
+
|
|
53
|
+
심각도: P2(보고된 P1에서 하향 — stored vault plaintext가 아니라 호출자 제공 입력을 반영함)
|
|
54
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #97)
|
|
55
|
+
영향 코드: `packages/token-vault/index.mjs`(reveal/purge throw + record), `packages/audit/index.mjs`(`FORBIDDEN_KEYS` / `sanitizeAudit`)
|
|
56
|
+
검증: reveal은 `Unknown token: ${token}` / `Token expired: ${token}`을 throw하고 catch가 `reason: error.message`를 기록한다; raw `token` 인자도 `reveal_failed`/`reveal_denied`/`purge`에 그대로 기록된다. `sanitizeAudit`는 key 이름으로 필터링하고 `FORBIDDEN_KEYS`는 `reason`도 `token`도 포함하지 않으므로, 둘 다 기록된 hash-chained 레코드로 살아남는다. 정상 흐름에서 `token` 인자는 비민감 `tok_<type>_<hash>` id이므로, 누출은 호출자/운영자가 token id가 기대되는 자리에 raw secret을 넘길 때만 발생한다 — 그래도 round-1 `P1-SEC-017`과 `threat-model.md`의 "no plaintext / keyed-HMAC only" 표현과 모순된다.
|
|
57
|
+
|
|
58
|
+
해소: (1) 일반화된 오류 메시지(raw token interpolation 없음); (2) raw `token`을 그대로 기록하는 것을 중단 — reveal/purge 레코드 이전에 (`subjectHash`/`issuerHash`처럼) keyed-HMAC하거나 인자를 `tok_<type>_<hash>` 형태에 대해 검증하고 아니면 redact; (3) free-text `reason: error.message`를 enum `reasonCode`로 교체; (4) `reveal_failed`/`purge` event가 `reason`/`token`에 호출자 제공 raw token을 절대 포함하지 않는다는 회귀 테스트; 문서의 불변식 표현을 정합화.
|
|
59
|
+
|
|
60
|
+
### CR2-003: Plugin IPC Reply가 Size-Bound되지 않음; Process Child에 Heap Cap 없음
|
|
61
|
+
|
|
62
|
+
심각도: P2
|
|
63
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #98)
|
|
64
|
+
영향 코드: `packages/plugin/sandbox.mjs`, `packages/plugin/process-sandbox.mjs`, `packages/cli/runtime.mjs`
|
|
65
|
+
검증: `maxMessageBytes`는 outbound host→plugin credential 메시지에만 강제된다; inbound reply는 두 sandbox 모두에서 size 검사 없이 수신·`JSON.parse`된다. worker에는 암묵적 경계가 있지만(필수 `resourceLimits` heap cap이 폭주 worker를 먼저 OOM시킴), process child는 `--max-old-space-size`를 설정하지 않으므로, 적대적/버그 있는 signed plugin이 child의 기본 V8 heap까지 reply를 만들어 `process.send`할 수 있고, host의 동기 `JSON.parse`가 event loop를 정지시킨다(per-call timeout이 parse 도중 발생할 수 없음). signed/semi-trusted-but-hostile plugin이 필요하다.
|
|
66
|
+
|
|
67
|
+
해소: 두 sandbox 모두에서 parse 이전에 reply를 경계화한다(worker/child `message` 핸들러에서 byte length를 `maxMessageBytes` 또는 전용 `maxReplyBytes`와 대조하고, oversized를 `JSON.parse` 이전에 deny로 drop); 새 `resourceLimits`/`processMaxOldGenerationSizeMb` knob에서 파생한 `--max-old-space-size`로 process child에 heap cap을 부여한다. 회귀 테스트: oversized claims 객체를 반환하는 fixture plugin → 무제한 host 작업 없이 deny. 1.0/1.1 scope 문서에 경계가 BOTH 방향에 적용됨을 명시.
|
|
68
|
+
|
|
69
|
+
### CR2-004: 변환된 응답의 Stale Body-Coupled Validator 헤더
|
|
70
|
+
|
|
71
|
+
심각도: P3
|
|
72
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #96)
|
|
73
|
+
영향 코드: `packages/proxy/index.mjs`의 `sanitizeResponseHeaders` / `transformedJsonHeaders`
|
|
74
|
+
검증: hop-by-hop 헤더만 제거된다; `protectJson`이 body를 변경·재직렬화할 때 upstream `etag`/`content-md5`/`digest`/`last-modified`가 그대로 살아남고 `cache-control: no-store`가 설정되지 않는다. 문서화된 inference-upstream 타깃 집합(POST 응답, strong validator 없음, RFC 9111상 기본 비캐시; `content-length`는 재계산됨)에서는 실세계 영향이 작지만, 수용 잔여 리스크로 기록되어 있지 않다.
|
|
75
|
+
|
|
76
|
+
해소: 모든 body-mutating 경로의 drop 집합에 `etag`/`content-md5`/`digest`/`last-modified`를 추가한다; 변환된 응답에 `cache-control: no-store`를 설정한다. 테스트: 변경된 응답이 upstream `ETag`를 더 이상 담지 않음.
|
|
77
|
+
|
|
78
|
+
### CR2-005: 한도 초과 Request Body가 Drain/Teardown되지 않음
|
|
79
|
+
|
|
80
|
+
심각도: P3
|
|
81
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #96)
|
|
82
|
+
영향 코드: `packages/proxy/index.mjs`의 `readBody`
|
|
83
|
+
검증: `maxBytes` 초과 시 `readBody`는 플래그를 세우고 reject하지만 request를 `pause()`/`destroy()`하지 않으며, 413 응답이 `Connection: close`를 보내지 않으므로 Node가 built-in `requestTimeout`(Node ≥22 기본 300000 ms)까지 업로드의 나머지를 read-and-discard한다. hold는 유한하다; `maxInFlight: 0`(기본값)은 동시에 hold되는 connection 수를 경계화하지 않는다.
|
|
84
|
+
|
|
85
|
+
해소: 413 시 `request.pause()`/`request.destroy()`(또는 응답 이전에 `Connection: close`)로 socket을 즉시 해제한다. 낮은 우선순위: non-null 기본 `requestTimeoutMs`/`headersTimeoutMs`를 출하하고 `maxInFlight: 0`이 동시성을 무제한으로 둔다는 점을 문서화.
|
|
86
|
+
|
|
87
|
+
### CR2-006: mcp-wrap `--stderr filter`가 Newline-Split Secret을 잡지 못함
|
|
88
|
+
|
|
89
|
+
심각도: P3(doc)
|
|
90
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #99)
|
|
91
|
+
영향 코드: `packages/cli/bin/haechi.mjs`의 `pipeFilteredStderr` / `protectStderrLine`
|
|
92
|
+
검증: `filter`는 child stderr를 `\n`으로 분할하고 매 완성된 라인을 fresh single-shot protector로 보호하므로, 적대적 child가 의도적으로 newline에 걸쳐 분할 방출한 secret은 anchored full-secret regex를 회피한다. 좁은 범위: child는 운영자의 신뢰된 로컬 MCP server이고, single-line secret은 잡히며, `--stderr drop`이 있다. 이것은 라인 지향 텍스트 필터링의 본질적 속성이지 request/response 보호 경로의 익스플로잇 가능한 우회가 아니다.
|
|
93
|
+
|
|
94
|
+
해소(doc): `COMMAND_HELP`와 이 등록부에 `filter`가 완성된 라인 단위로 보호하며 newline에 걸쳐 분할된 secret을 잡지 못한다는 한 문장을 명시; 고민감 도구에는 `--stderr drop`을 권장. 선택적 후속 코드 하드닝: stderr를 per-line `protectText` 대신 push/flush sliding-buffer 채널(`maxMatchBytes`)로 라우팅.
|
|
95
|
+
|
|
96
|
+
### CR2-007: README mcp-wrap stderr Passthrough가 Stale
|
|
97
|
+
|
|
98
|
+
심각도: P3(doc)
|
|
99
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #99)
|
|
100
|
+
영향 코드: `README.md`
|
|
101
|
+
검증: README는 "stderr and exit codes pass through"라고 하지만, 기본값은 이제 `--stderr filter`다(round-1 P2-CR-006); raw passthrough는 opt-in `inherit` 모드뿐이다. exit code는 실제로 pass through되므로 stderr 절만 stale하다; `COMMAND_HELP`는 이미 정확하다.
|
|
102
|
+
|
|
103
|
+
해소(doc): README 줄을 `filter` 기본값을 반영하도록 수정(`inherit`은 raw, `drop`은 폐기; `filter`는 `policy.mode: enforce`에서만 변환); `README.ko.md` sibling 갱신.
|
|
104
|
+
|
|
105
|
+
### CR2-008: README Streaming Split-Match 주장이 범위 한정 없음
|
|
106
|
+
|
|
107
|
+
심각도: P3(doc)
|
|
108
|
+
상태: Resolved (`haechi@1.3.2`로 발행, PR #99)
|
|
109
|
+
영향 코드: `README.md`
|
|
110
|
+
검증: README는 frame에 걸쳐 분할된 PII가 잡힌다고 주장하면서 이를 JSON delta 채널로 한정하지 않는다; non-JSON CONTENT frame은 single-shot per-frame `protectText`를 받는다(cross-frame buffer 없음). 이 주장은 `threat-model.md`와 scope 문서 대비 보장을 과장한다.
|
|
111
|
+
|
|
112
|
+
해소(doc): README 두 구절을 모두 delta 채널로 한정(`maxMatchBytes`까지 frame에 걸쳐 분할된 delta-text PII; non-delta leaf와 non-JSON frame은 within-frame 검사); `README.ko.md` 갱신.
|
|
113
|
+
|
|
114
|
+
### CR2-009: maxMessageBytes 검사 이후의 keyMaterial — FALSE POSITIVE
|
|
115
|
+
|
|
116
|
+
심각도: —(보고된 P2; 취약점이 아님으로 검증)
|
|
117
|
+
상태: Won't fix
|
|
118
|
+
영향 코드: `packages/plugin/process-sandbox.mjs`, `packages/cli/runtime.mjs`
|
|
119
|
+
검증: 구조적 관찰(`keyMaterial` append 이후 결합 메시지가 재검사되지 않음)은 정확하지만, 공격자가 익스플로잇할 수 없다. `keyMaterial`은 운영자 통제이고(host가 운영자 선언 HTTPS URL에서 fetch, TTL 캐시, 공격자 영향 credential과 독립) guarded fetcher의 `maxBytes`(기본 1 MiB)로 hard-bound된다; credential은 base 검사로 경계가 유지된다. 결합 wire는 두 운영자 설정 상수로 경계화되며 공격자 증폭이 없다; "`maxBytes`를 임의로 크게"는 운영자 자체 오설정이다. 선택적 cosmetic defense-in-depth만 가능(결합 size re-assert); 보안 수정 불필요.
|
|
120
|
+
|
|
121
|
+
### CR2-010: Non-JSON Cross-Frame Split — 수용 잔여 리스크(문서화됨)
|
|
122
|
+
|
|
123
|
+
심각도: —(보고된 P2; 이미 문서화된 잔여 리스크)
|
|
124
|
+
상태: Accepted
|
|
125
|
+
영향 코드: `packages/core/index.mjs` / `packages/stream-filter/index.mjs`
|
|
126
|
+
검증: 1.3.1에서 실재한다(non-JSON CONTENT frame은 cross-frame buffer 없이 per-frame `protectText`를 받음). 하지만 round-1 `P1-CR-005` resolution, `threat-model.md` exclusions, in-code comment에 범위 외로 명시 문서화되어 있다. JSON delta 채널은 `maxMatchBytes`까지 cross-frame buffering을 한다. 코드 변경 불필요; 기껏해야 문서 다듬기 차원의 sibling exclusion 항목(CR2-008의 README scoping에 흡수).
|
|
127
|
+
|
|
128
|
+
## 보완 순서
|
|
129
|
+
|
|
130
|
+
1. `CR2-001`을 최우선으로 — 전제 조건 없이 인증되지 않은 클라이언트가 도달 가능한 유일한 항목(availability).
|
|
131
|
+
2. `CR2-002`와 `CR2-003`을 병렬로 — 파일이 disjoint하다(token-vault+audit vs plugin sandbox).
|
|
132
|
+
3. `CR2-004` + `CR2-005`를 함께(둘 다 `proxy/index.mjs`; CR2-001 이후 / 그 위에 rebase해 착륙).
|
|
133
|
+
4. `CR2-006` + `CR2-007` + `CR2-008` — 문서/help-text 묶음, 아무 때나.
|
|
134
|
+
5. `CR2-009` / `CR2-010`은 코드 변경 불필요(audit trail용 기록).
|
|
135
|
+
|
|
136
|
+
## 종료 규칙
|
|
137
|
+
|
|
138
|
+
항목은 코드/문서 보완이 merge되고, 집중 회귀 테스트 또는 명시적 non-test rationale이 기록되며, 릴리스 게이트 등록부(`G10`)가 증거를 링크할 때만 `Resolved`로 옮긴다. 1.3.2 컷이 resolved 항목과 `G10`을 함께 뒤집는다.
|
|
139
|
+
|
|
140
|
+
## 추적 링크
|
|
141
|
+
|
|
142
|
+
`docs/current/risk-register-release-gate.md`(§5.8 + `G10`)와 `docs/current/risk-register-release-gate.ko.md`에서 참조한다.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# 2026-06-16 Code Review Risk Register — Round 2 (CR2)
|
|
2
|
+
|
|
3
|
+
Status: remediation complete and shipped in `haechi@1.3.2` (no P0/P1; CR2-001..008 Resolved; CR2-009 won't-fix, CR2-010 accepted; G10 Pass, 2026-06-16)
|
|
4
|
+
Scope: `main` at `36af9fd1eef2b1e19b19b2e0344faab0a7a3e83d` (post-1.3.1)
|
|
5
|
+
Review date: 2026-06-16
|
|
6
|
+
Source: a second deep review after the 1.3.1 remediation cut, with per-finding adversarial verification against the current code
|
|
7
|
+
|
|
8
|
+
This is a **second round** kept separate from `code-review-risk-register-2026-06-16.md` (round 1, all Resolved + shipped in 1.3.1), which is a frozen resolution record cited by the threat model and code. Round 2 captures findings raised after 1.3.1 and re-verified against the current tree; a few of them extend or qualify round-1 claims, so they are cross-referenced rather than written back into the frozen record.
|
|
9
|
+
|
|
10
|
+
## Release Decision
|
|
11
|
+
|
|
12
|
+
The round-1 P0/P1 are all Resolved and shipped in `haechi@1.3.1`. Round 2 found **no P0 and no P1**: the two findings raised as P1 in the external review both verified down to **P2** (neither is a stored-plaintext leak or an auth/SSRF bypass). The confirmed P2s are an availability/resource leak on the proxy data path, an audit-hygiene gap that reflects caller-supplied input, and an unbounded plugin IPC reply. The three P2s and the P3 cluster (`CR2-001..008`) are now **Resolved and shipped in `haechi@1.3.2`** (2026-06-16, attested OIDC publish); `CR2-009` stays won't-fix (false positive) and `CR2-010` stays accepted (documented residual). **G10 is Pass.** Operators should upgrade from `1.3.1` to `1.3.2` to pick up the CR2 fixes.
|
|
13
|
+
|
|
14
|
+
## Severity Policy
|
|
15
|
+
|
|
16
|
+
- `P0`: direct credential/data leak across a trust boundary, or a bypass that defeats the core security promise.
|
|
17
|
+
- `P1`: SSRF, protection bypass, denial-of-service, or protocol behavior that can break protected deployments.
|
|
18
|
+
- `P2`: operational, correctness, availability, or hygiene gaps that should be resolved before broad adoption.
|
|
19
|
+
- `P3`: low-impact hardening, finite-bound robustness, or documentation accuracy.
|
|
20
|
+
|
|
21
|
+
## Verification note
|
|
22
|
+
|
|
23
|
+
Every finding below was traced against the current code by an independent reviewer (not taken on the reporter's word). Two reported P1s were downgraded to P2 after verification (no inflation); one reported finding was a **false positive** and one is an **already-documented accepted residual** — both are recorded here for the audit trail and require no code change.
|
|
24
|
+
|
|
25
|
+
## Summary
|
|
26
|
+
|
|
27
|
+
| ID | Severity | Area | Risk | Status |
|
|
28
|
+
| --- | --- | --- | --- | --- |
|
|
29
|
+
| CR2-001 | P2 | Proxy availability | Pass-through streaming never cancels the upstream reader on downstream client disconnect — `await once(response,"drain")` parks forever, leaking the upstream connection/task; an unauthenticated client can disconnect repeatedly to accumulate dangling upstream connections. | Resolved |
|
|
30
|
+
| CR2-002 | P2 | Audit hygiene | Token-vault reveal/purge failures write the raw caller-supplied `token` and `error.message` (which interpolates the token) into the audit event; `FORBIDDEN_KEYS` strips by key name only, so a secret passed where a `tok_` id is expected lands raw in the hash-chained log. Reflects caller input, not stored vault plaintext. | Resolved |
|
|
31
|
+
| CR2-003 | P2 | Plugin sandbox DoS | `maxMessageBytes` bounds only the host→plugin credential message; the plugin→host reply is received and `JSON.parse`d unbounded. The process-isolated child has no heap cap, so a hostile/buggy signed plugin can return an oversized reply → synchronous host parse stalls the event loop + memory spike. | Resolved |
|
|
32
|
+
| CR2-004 | P3 | Proxy headers | `sanitizeResponseHeaders` keeps body-coupled validators (`etag`/`content-md5`/`digest`/`last-modified`) when the body is transformed, so they become stale; no `cache-control: no-store` on a mutated response. | Resolved |
|
|
33
|
+
| CR2-005 | P3 | Proxy robustness | On a body over `maxBytes`, `readBody` rejects but does not stop reading/teardown the socket, so an upload is read-and-discarded until the (finite) Node `requestTimeout`. | Resolved |
|
|
34
|
+
| CR2-006 | P3 | MCP wrap | `mcp-wrap --stderr filter` protects per complete line, so a secret an adversarial child deliberately splits across a newline evades the anchored regex. Inherent to line-oriented filtering of a trusted local child's diagnostic output; a single-line secret IS caught and `--stderr drop` exists. Doc-only. | Resolved |
|
|
35
|
+
| CR2-007 | P3 | Docs | README says MCP wrap "stderr and exit codes pass through", but the default is now `--stderr filter` (round-1 P2-CR-006). | Resolved |
|
|
36
|
+
| CR2-008 | P3 | Docs | README's streaming "split match" claim is unscoped; cross-frame buffering applies to the JSON delta channel only, not arbitrary non-JSON frames. | Resolved |
|
|
37
|
+
| CR2-009 | — | Plugin sandbox | (Reported P2) `keyMaterial` is appended after the `maxMessageBytes` check on the base credential message. **FALSE POSITIVE:** `keyMaterial` is operator-controlled and hard-bounded by the fetcher's `maxBytes`; no attacker amplification. No fix required (optional cosmetic re-assert only). | Won't fix |
|
|
38
|
+
| CR2-010 | — | Streaming | (Reported P2) A secret split across two NON-JSON SSE/NDJSON frames is not caught (per-frame inspection). **ACCEPTED RESIDUAL — already documented** in round-1 P1-CR-005 resolution, `threat-model.md` exclusions, and an in-code comment. No change. | Accepted |
|
|
39
|
+
|
|
40
|
+
## Detailed Findings
|
|
41
|
+
|
|
42
|
+
### CR2-001: Upstream Reader Not Cancelled On Downstream Disconnect
|
|
43
|
+
|
|
44
|
+
Severity: P2 (the most urgent CR2 item)
|
|
45
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #96)
|
|
46
|
+
Affected code: `packages/proxy/index.mjs` `pipeUpstreamBodyBounded` / `forward`
|
|
47
|
+
Verified: in the pass-through streaming path there is no `close`/`aborted` listener on the client connection; `await once(response, "drain")` parks indefinitely after the client socket dies (neither `drain` nor `error` fires), so the async task and the upstream connection leak. Reachable by an **unauthenticated** client with no preconditions; repeated mid-stream disconnects accumulate dangling upstream connections against the proxy and its upstream LLM endpoint.
|
|
48
|
+
|
|
49
|
+
Resolution: pass a per-request `AbortController` into `forward()` (aborting the upstream fetch) and register a one-shot client `close`/`aborted` listener that cancels the upstream reader; race the `drain` wait against `close` so backpressure waits unpark on disconnect; cover the no-backpressure `reader.read()` parked case too. Regression test: disconnect mid-stream and assert prompt reader cancellation / upstream abort.
|
|
50
|
+
|
|
51
|
+
### CR2-002: Token-Vault Reveal/Purge Writes Raw Token + Error Text To Audit
|
|
52
|
+
|
|
53
|
+
Severity: P2 (downgraded from a reported P1 — it reflects caller-supplied input, not stored vault plaintext)
|
|
54
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #97)
|
|
55
|
+
Affected code: `packages/token-vault/index.mjs` (reveal/purge throw + record), `packages/audit/index.mjs` (`FORBIDDEN_KEYS` / `sanitizeAudit`)
|
|
56
|
+
Verified: reveal throws `Unknown token: ${token}` / `Token expired: ${token}` and the catch records `reason: error.message`; the raw `token` argument is also written verbatim on `reveal_failed`/`reveal_denied`/`purge`. `sanitizeAudit` filters by key name and `FORBIDDEN_KEYS` contains neither `reason` nor `token`, so both survive into the written, hash-chained record. In legitimate flows the `token` argument is a non-sensitive `tok_<type>_<hash>` id, so the leak only fires when a caller/operator passes a raw secret where a token id is expected — but it still contradicts the "no plaintext / keyed-HMAC only" wording in round-1 `P1-SEC-017` and `threat-model.md`.
|
|
57
|
+
|
|
58
|
+
Resolution: (1) generic error messages (no raw token interpolation); (2) stop writing the raw `token` verbatim — keyed-HMAC it (as `subjectHash`/`issuerHash` are) or validate the argument against the `tok_<type>_<hash>` shape and redact otherwise, before reveal/purge records; (3) replace free-text `reason: error.message` with an enum `reasonCode`; (4) regression test that a `reveal_failed`/`purge` event never contains a raw caller-supplied token in `reason`/`token`; reconcile the invariant wording in the docs.
|
|
59
|
+
|
|
60
|
+
### CR2-003: Plugin IPC Reply Not Size-Bounded; Process Child Has No Heap Cap
|
|
61
|
+
|
|
62
|
+
Severity: P2
|
|
63
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #98)
|
|
64
|
+
Affected code: `packages/plugin/sandbox.mjs`, `packages/plugin/process-sandbox.mjs`, `packages/cli/runtime.mjs`
|
|
65
|
+
Verified: `maxMessageBytes` is enforced only on the outbound host→plugin credential message; the inbound reply is received and `JSON.parse`d with no size check in both sandboxes. The worker has an implicit bound (a required `resourceLimits` heap cap OOMs a runaway worker first), but the process child sets no `--max-old-space-size`, so a hostile/buggy signed plugin can build a reply up to the child's default V8 heap and `process.send` it; the host's synchronous `JSON.parse` stalls the event loop (the per-call timeout cannot fire mid-parse). Requires a signed/semi-trusted-but-hostile plugin.
|
|
66
|
+
|
|
67
|
+
Resolution: bound the reply BEFORE parsing in both sandboxes (check byte length against `maxMessageBytes` or a dedicated `maxReplyBytes` in the worker/child `message` handler, drop oversized as a deny before `JSON.parse`); give the process child a heap cap via `--max-old-space-size` derived from a new `resourceLimits`/`processMaxOldGenerationSizeMb` knob. Regression test: a fixture plugin returning an oversized claims object → deny without unbounded host work. Update the 1.0/1.1 scope docs to state the bound applies in BOTH directions.
|
|
68
|
+
|
|
69
|
+
### CR2-004: Stale Body-Coupled Validator Headers On Transformed Responses
|
|
70
|
+
|
|
71
|
+
Severity: P3
|
|
72
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #96)
|
|
73
|
+
Affected code: `packages/proxy/index.mjs` `sanitizeResponseHeaders` / `transformedJsonHeaders`
|
|
74
|
+
Verified: only hop-by-hop headers are stripped; when `protectJson` mutates and re-serializes the body, upstream `etag`/`content-md5`/`digest`/`last-modified` survive unchanged and no `cache-control: no-store` is set. Real-world impact is small for the documented inference-upstream target set (POST responses, no strong validators, not cacheable by default per RFC 9111; `content-length` IS recomputed), but it is not recorded as an accepted residual.
|
|
75
|
+
|
|
76
|
+
Resolution: add `etag`/`content-md5`/`digest`/`last-modified` to the dropped set on every body-mutating path; set `cache-control: no-store` on a transformed response. Test: a mutated response no longer carries the upstream `ETag`.
|
|
77
|
+
|
|
78
|
+
### CR2-005: Over-Limit Request Body Not Drained/Torn Down
|
|
79
|
+
|
|
80
|
+
Severity: P3
|
|
81
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #96)
|
|
82
|
+
Affected code: `packages/proxy/index.mjs` `readBody`
|
|
83
|
+
Verified: on exceeding `maxBytes`, `readBody` sets a flag and rejects but does not `pause()`/`destroy()` the request, and the 413 response sends no `Connection: close`, so Node reads-and-discards the rest of the upload until the built-in `requestTimeout` (Node ≥22 default 300000 ms). The hold is finite; `maxInFlight: 0` (default) does not bound simultaneous held connections.
|
|
84
|
+
|
|
85
|
+
Resolution: on 413, `request.pause()`/`request.destroy()` (or `Connection: close` before the response) so the socket releases promptly. Lower priority: ship non-null default `requestTimeoutMs`/`headersTimeoutMs` and document that `maxInFlight: 0` leaves concurrency unbounded.
|
|
86
|
+
|
|
87
|
+
### CR2-006: mcp-wrap `--stderr filter` Cannot Catch A Newline-Split Secret
|
|
88
|
+
|
|
89
|
+
Severity: P3 (doc)
|
|
90
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #99)
|
|
91
|
+
Affected code: `packages/cli/bin/haechi.mjs` `pipeFilteredStderr` / `protectStderrLine`
|
|
92
|
+
Verified: `filter` splits child stderr on `\n` and protects each complete line with a fresh single-shot protector, so a secret an adversarial child deliberately emits split across a newline evades the anchored full-secret regex. Narrow: the child is the operator's trusted local MCP server, a single-line secret IS caught, and `--stderr drop` exists. This is an inherent property of line-oriented text filtering, not an exploitable bypass of the request/response protection path.
|
|
93
|
+
|
|
94
|
+
Resolution (doc): one sentence in `COMMAND_HELP` and this register noting `filter` protects per complete line and cannot catch a secret split across a newline; recommend `--stderr drop` for high-sensitivity tools. Optional later code hardening: route stderr through the push/flush sliding-buffer channel (`maxMatchBytes`) instead of per-line `protectText`.
|
|
95
|
+
|
|
96
|
+
### CR2-007: README mcp-wrap stderr Passthrough Is Stale
|
|
97
|
+
|
|
98
|
+
Severity: P3 (doc)
|
|
99
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #99)
|
|
100
|
+
Affected code: `README.md`
|
|
101
|
+
Verified: README says "stderr and exit codes pass through", but the default is now `--stderr filter` (round-1 P2-CR-006); raw passthrough is only the opt-in `inherit` mode. Exit codes do pass through, so only the stderr clause is stale; `COMMAND_HELP` is already accurate.
|
|
102
|
+
|
|
103
|
+
Resolution (doc): correct the README line to reflect the `filter` default (`inherit` for raw, `drop` to discard; `filter` transforms only under `policy.mode: enforce`); update the `README.ko.md` sibling.
|
|
104
|
+
|
|
105
|
+
### CR2-008: README Streaming Split-Match Claim Is Unscoped
|
|
106
|
+
|
|
107
|
+
Severity: P3 (doc)
|
|
108
|
+
Status: Resolved (shipped in `haechi@1.3.2`, PR #99)
|
|
109
|
+
Affected code: `README.md`
|
|
110
|
+
Verified: the README claims PII split across frames is caught without scoping it to the JSON delta channel; non-JSON CONTENT frames get single-shot per-frame `protectText` (no cross-frame buffer). The claim overstates the guarantee relative to `threat-model.md` and the scope docs.
|
|
111
|
+
|
|
112
|
+
Resolution (doc): scope both README passages to the delta channel (delta-text PII split across frames up to `maxMatchBytes`; non-delta leaves and non-JSON frames inspected within-frame); update `README.ko.md`.
|
|
113
|
+
|
|
114
|
+
### CR2-009: keyMaterial After the maxMessageBytes Check — FALSE POSITIVE
|
|
115
|
+
|
|
116
|
+
Severity: — (reported P2; verified not a vulnerability)
|
|
117
|
+
Status: Won't fix
|
|
118
|
+
Affected code: `packages/plugin/process-sandbox.mjs`, `packages/cli/runtime.mjs`
|
|
119
|
+
Verified: the structural observation (the combined message is not re-checked after `keyMaterial` is appended) is accurate, but it is not attacker-exploitable. `keyMaterial` is operator-controlled (fetched by the host from an operator-declared HTTPS URL, TTL-cached, independent of the attacker-influenced credential) and hard-bounded by the guarded fetcher's `maxBytes` (default 1 MiB); the credential stays bounded by the base check. The combined wire is bounded by two operator-set constants with no attacker amplification; "`maxBytes` arbitrarily large" is operator self-misconfiguration. Optional cosmetic defense-in-depth only (re-assert the combined size); no security fix required.
|
|
120
|
+
|
|
121
|
+
### CR2-010: Non-JSON Cross-Frame Split — ACCEPTED RESIDUAL (documented)
|
|
122
|
+
|
|
123
|
+
Severity: — (reported P2; an already-documented residual)
|
|
124
|
+
Status: Accepted
|
|
125
|
+
Affected code: `packages/core/index.mjs` / `packages/stream-filter/index.mjs`
|
|
126
|
+
Verified: real in 1.3.1 (non-JSON CONTENT frames get per-frame `protectText` with no cross-frame buffer), but it is explicitly documented as out-of-scope in round-1 `P1-CR-005` resolution, the `threat-model.md` exclusions, and an in-code comment. The JSON delta channel DOES cross-frame buffer up to `maxMatchBytes`. No code change required; at most a documentation-polish sibling exclusion bullet (folded into CR2-008's README scoping).
|
|
127
|
+
|
|
128
|
+
## Remediation Order
|
|
129
|
+
|
|
130
|
+
1. `CR2-001` first — the only finding reachable by an unauthenticated client with no preconditions (availability).
|
|
131
|
+
2. `CR2-002` and `CR2-003` in parallel — file-disjoint (token-vault+audit vs plugin sandbox).
|
|
132
|
+
3. `CR2-004` + `CR2-005` together (both `proxy/index.mjs`; land after / rebased on CR2-001).
|
|
133
|
+
4. `CR2-006` + `CR2-007` + `CR2-008` — documentation/help-text cluster, anytime.
|
|
134
|
+
5. `CR2-009` / `CR2-010` need no code change (recorded for the audit trail).
|
|
135
|
+
|
|
136
|
+
## Closure Rules
|
|
137
|
+
|
|
138
|
+
An item moves to `Resolved` only when the code/doc remediation is merged, a focused regression test or explicit non-test rationale is recorded, and the release-gate register (`G10`) links the evidence. The 1.3.2 cut flips the resolved items and `G10` together.
|
|
139
|
+
|
|
140
|
+
## Traceability
|
|
141
|
+
|
|
142
|
+
Linked from `docs/current/risk-register-release-gate.md` (§5.8 + `G10`) and `docs/current/risk-register-release-gate.ko.md`.
|
|
@@ -21,6 +21,17 @@ docker compose up -d # 참조 스택 빌드 + 실행
|
|
|
21
21
|
docker compose logs -f haechi
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
**사전 빌드 이미지(GHCR).** 각 `v<semver>` 릴리스는 cosign 서명된 이미지를 `ghcr.io/<owner>/haechi`에 발행하며(태그 `<major>.<minor>.<patch>`, `<major>.<minor>`, `<major>`, `latest`), 실행 전에 검증하십시오 — 서명과 provenance가 이미지를 이 repo의 release workflow에 묶습니다:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cosign verify ghcr.io/<owner>/haechi:1.3.3 \
|
|
28
|
+
--certificate-identity-regexp '^https://github.com/<owner>/haechi/' \
|
|
29
|
+
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
|
30
|
+
gh attestation verify oci://ghcr.io/<owner>/haechi:1.3.3 --repo <owner>/haechi
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
이미지는 `proxy.trustForwardedProto: true`를 구워 넣으므로(TLS를 종단하는 리버스 프록시 뒤에서 `0.0.0.0`에 바인딩 — 아래 참조), Haechi는 보호되는 모든 요청에 `X-Forwarded-Proto: https`를 요구합니다. Haechi가 직접 TLS를 종단하게 하려면 `proxy.tls`가 설정된 본인의 설정을 마운트하십시오.
|
|
34
|
+
|
|
24
35
|
**TLS + 인증으로 앞단을 보호하십시오.** Haechi는 자체 TLS가 없습니다. 포트는 TLS를 종단하고 인증하는 리버스 프록시(nginx / Caddy / Traefik / API 게이트웨이)에만 공개하고, 원시 Haechi 포트를 공개 인터페이스에 절대 노출하지 마십시오. compose 예제는 바로 이 이유로 호스트 loopback(`127.0.0.1:11016`)에만 공개합니다.
|
|
25
36
|
|
|
26
37
|
**Loopback 너머 바인딩.** 컨테이너 내부에서는 매핑된 포트가 도달 가능하도록 Haechi가 `0.0.0.0`에 바인딩해야 하며, 이는 `--allow-remote-bind`를 요구합니다(참조 `CMD`가 전달합니다). 호스트에서는 기본 loopback 바인딩을 선호하고 리버스 프록시를 통해 Haechi에 접근하십시오. [Loopback 너머 바인딩](./configuration.ko.md)을 참고하십시오.
|
|
@@ -140,7 +151,27 @@ HAECHI_BENCH_REQUESTS=5000 HAECHI_BENCH_CONCURRENCY=64 npm run bench:throughput
|
|
|
140
151
|
> 네트워크/하드웨어 처리량 벤치마크가 **아니며** 보장 수치로 인용해서는 **안
|
|
141
152
|
> 됩니다**. 이 벤치는 `release:preflight`에서 실행되지 않습니다.
|
|
142
153
|
|
|
143
|
-
## 8.
|
|
154
|
+
## 8. 실 업스트림 검증 (real vLLM / Ollama)
|
|
155
|
+
|
|
156
|
+
`local-inference` 통합 스위트는 요청을 **실제** OpenAI 호환(vLLM) 및/또는
|
|
157
|
+
Ollama 업스트림으로 프록시하여, 프록시가 올바르게 왕복하는지 검증합니다(실제
|
|
158
|
+
소켓 위에서의 adapter 라우팅 + 요청/응답 보호). 이 스위트는 env-gated되어, 백엔드를
|
|
159
|
+
가리키지 않으면 **스킵**합니다 — CI는 프로토콜 스텁을 상대로 실행합니다(실제 vLLM은
|
|
160
|
+
GPU가 필요하고 GitHub 호스팅 러너에서 도달할 수 없습니다). 도달 가능한 호스트에서
|
|
161
|
+
본인의 백엔드를 상대로 검증하려면:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
HAECHI_VLLM_URL=http://VLLM_HOST:8000 HAECHI_VLLM_MODEL=<served-model> \
|
|
165
|
+
HAECHI_OLLAMA_URL=http://OLLAMA_HOST:11434 HAECHI_OLLAMA_MODEL=<pulled-model> \
|
|
166
|
+
npm run test:inference:live
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
보유한 백엔드만 설정하십시오 — 각 테스트는 해당 URL이 설정되지 않으면 스킵합니다.
|
|
170
|
+
본인의 호스트/IP를 사용하십시오(커밋하지 마십시오). 지속적으로 구동되는 실제 백엔드
|
|
171
|
+
게이트가 필요하면, 해당 네트워크에 self-hosted 러너를 등록하고 그곳에서 스위트를
|
|
172
|
+
트리거하십시오. GitHub 호스팅 러너는 사설 LAN에 도달할 수 없습니다.
|
|
173
|
+
|
|
174
|
+
## 9. 빠른 참조
|
|
144
175
|
|
|
145
176
|
| 작업 | 커맨드 |
|
|
146
177
|
|---|---|
|
|
@@ -30,6 +30,23 @@ docker compose up -d # build + run the reference stack
|
|
|
30
30
|
docker compose logs -f haechi
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
**Pre-built image (GHCR).** Each `v<semver>` release publishes a cosign-signed
|
|
34
|
+
image to `ghcr.io/<owner>/haechi` (tags `<major>.<minor>.<patch>`, `<major>.<minor>`,
|
|
35
|
+
`<major>`, `latest`). Verify it before running — the signature and provenance bind
|
|
36
|
+
the image to this repo's release workflow:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cosign verify ghcr.io/<owner>/haechi:1.3.3 \
|
|
40
|
+
--certificate-identity-regexp '^https://github.com/<owner>/haechi/' \
|
|
41
|
+
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
|
42
|
+
gh attestation verify oci://ghcr.io/<owner>/haechi:1.3.3 --repo <owner>/haechi
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The image bakes `proxy.trustForwardedProto: true` (it binds `0.0.0.0` behind a
|
|
46
|
+
TLS-terminating reverse proxy — see below), so Haechi requires `X-Forwarded-Proto:
|
|
47
|
+
https` on every protected request; mount your own config with `proxy.tls` set
|
|
48
|
+
instead if you want Haechi to terminate TLS itself.
|
|
49
|
+
|
|
33
50
|
**Front it with TLS + auth.** Haechi has no TLS of its own. Publish its port only
|
|
34
51
|
to a TLS-terminating, authenticating reverse proxy (nginx / Caddy / Traefik / an
|
|
35
52
|
API gateway); never expose the raw Haechi port on a public interface. The compose
|
|
@@ -225,7 +242,28 @@ default 2000), `HAECHI_BENCH_CONCURRENCY` (default 32), `HAECHI_BENCH_WARMUP`
|
|
|
225
242
|
> and must **not** be quoted as guarantees. The bench is not run by
|
|
226
243
|
> `release:preflight`.
|
|
227
244
|
|
|
228
|
-
## 8.
|
|
245
|
+
## 8. Live upstream validation (real vLLM / Ollama)
|
|
246
|
+
|
|
247
|
+
The `local-inference` integration suite proxies a request through to a **real**
|
|
248
|
+
OpenAI-compatible (vLLM) and/or Ollama upstream and asserts the proxy round-trips
|
|
249
|
+
correctly (adapter routing + request/response protection over a real socket). It
|
|
250
|
+
is env-gated, so it **skips** unless you point it at a backend — CI runs it
|
|
251
|
+
against a protocol stub (a real vLLM needs a GPU and is not reachable from a
|
|
252
|
+
GitHub-hosted runner). To validate against your own backend from a host that can
|
|
253
|
+
reach it:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
HAECHI_VLLM_URL=http://VLLM_HOST:8000 HAECHI_VLLM_MODEL=<served-model> \
|
|
257
|
+
HAECHI_OLLAMA_URL=http://OLLAMA_HOST:11434 HAECHI_OLLAMA_MODEL=<pulled-model> \
|
|
258
|
+
npm run test:inference:live
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Set only the backend(s) you have — each test skips when its URL is unset. Use
|
|
262
|
+
your own host/IP (do not commit it). For a continuously-exercised real-backend
|
|
263
|
+
gate, register a self-hosted runner on that network and trigger the suite there;
|
|
264
|
+
GitHub-hosted runners cannot reach a private LAN.
|
|
265
|
+
|
|
266
|
+
## 9. Quick reference
|
|
229
267
|
|
|
230
268
|
| Task | Command |
|
|
231
269
|
|---|---|
|
|
@@ -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` 확인.
|
|
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
|
|
|
@@ -66,6 +68,7 @@ npm audit signatures
|
|
|
66
68
|
|---|---|---|---|
|
|
67
69
|
| `.github/workflows/ci.yml` | — | 모든 push/PR | test, release preflight, SBOM artifact |
|
|
68
70
|
| `.github/workflows/npm-publish.yml` | `haechi` | `v<semver>` | npm provenance publish + 체크섬/증명 release 자산 |
|
|
71
|
+
| `.github/workflows/container-publish.yml` | `ghcr.io/<owner>/haechi` 이미지 | `v<semver>` | 루트 Dockerfile 빌드, GHCR로 push, digest 기준 keyless cosign 서명 + sigstore build-provenance 증명 |
|
|
69
72
|
| `.github/workflows/crypto-kms-publish.yml` | `haechi-crypto-kms` | `crypto-kms-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
70
73
|
| `.github/workflows/auth-jwt-publish.yml` | `haechi-auth-jwt` | `auth-jwt-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
71
74
|
| `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
@@ -80,10 +83,16 @@ Satellite는 npm workspaces 모노레포의 `satellites/*`에 살며 core와 **
|
|
|
80
83
|
|
|
81
84
|
**satellite별 부트스트랩 순서(첫 발행, org 불필요):**
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
2. 접두사 태그를 push하고 GitHub Release를 발행하면(예: `crypto-kms-v0.1.0`) 워크플로의 OIDC publish가 provenance와 함께 `0.1.0`을 생성하고 첫 발행 시 이름을 확보합니다.
|
|
86
|
+
아직 존재하지 않는 이름에는 Trusted Publisher를 설정할 **수 없습니다** — npm은 **이미 존재하는** 패키지의 설정 페이지에서만 Trusted Publisher 설정을 노출합니다. 따라서 완전히 새로운 unscoped 이름은 두 단계 부트스트랩을 거칩니다: 먼저 수동 첫 발행으로 이름을 *생성하고 확보*한 뒤, Trusted Publisher를 설정하여 이후 모든 버전이 OIDC로 증명되게 합니다.
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
1. **수동 첫 발행(이름 확보; 로컬, provenance 없음).** satellite 디렉터리에서, 패스키/WebAuthn 계정이 터미널 OTP 없이 인증되도록 브라우저로 인증한 뒤 provenance를 끄고 발행합니다(로컬 머신에는 OIDC id-token이 없어 증명할 수 없습니다).
|
|
89
|
+
```bash
|
|
90
|
+
npm login --auth-type=web
|
|
91
|
+
cd satellites/<name> && npm publish --auth-type=web --provenance=false
|
|
92
|
+
```
|
|
93
|
+
각 satellite `package.json`의 `publishConfig.access: "public"`이 unscoped 패키지를 public으로 만듭니다. 이 첫 버전은 **비증명**입니다 — §2 / `CONTRIBUTING.md`에 따라 갭을 기록하세요.
|
|
94
|
+
2. **이제 패키지가 존재하므로 → Trusted Publisher 설정**: npmjs.com에서 package settings → Trusted Publisher → `raeseoklee/haechi` 저장소와 satellite의 **정확한 워크플로 파일명**(예: `crypto-kms-publish.yml`)을 연결합니다.
|
|
95
|
+
3. **이후 모든 버전은 OIDC로 증명됩니다.** satellite `package.json`을 bump하고, 접두사 태그를 push한 뒤, GitHub Release를 발행하면(예: `crypto-kms-v0.1.1`) 워크플로의 OIDC publish가 provenance와 함께 해당 버전을 발행합니다. 이 시점부터는 노트북도 OTP도 필요 없습니다. 이름이 unscoped이고 비어있으므로 org-membership 선행 요건이 없습니다.
|
|
87
96
|
|
|
88
97
|
**태그 → 워크플로 → 패키지 매핑:**
|
|
89
98
|
|
|
@@ -104,9 +113,9 @@ npm view haechi-crypto-kms --json # dist.attestations 존재 확인; access "p
|
|
|
104
113
|
|
|
105
114
|
**의존성 노트:** `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
115
|
|
|
107
|
-
**0.9 satellite(새 unscoped 이름
|
|
116
|
+
**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
117
|
|
|
109
|
-
**`haechi-ratelimit-redis`(
|
|
118
|
+
**`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
119
|
|
|
111
120
|
## 6. 배포 차단 조건
|
|
112
121
|
|
|
@@ -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.
|
|
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
|
|
|
@@ -66,6 +68,7 @@ npm audit signatures
|
|
|
66
68
|
|---|---|---|---|
|
|
67
69
|
| `.github/workflows/ci.yml` | — | any push/PR | Tests, release preflight, SBOM artifact |
|
|
68
70
|
| `.github/workflows/npm-publish.yml` | `haechi` | `v<semver>` | npm provenance publish + checksummed/attested release assets |
|
|
71
|
+
| `.github/workflows/container-publish.yml` | `ghcr.io/<owner>/haechi` image | `v<semver>` | Build the root Dockerfile, push to GHCR, keyless cosign sign by digest + sigstore build-provenance attestation |
|
|
69
72
|
| `.github/workflows/crypto-kms-publish.yml` | `haechi-crypto-kms` | `crypto-kms-v<semver>` | satellite publish, same signed-artifacts path |
|
|
70
73
|
| `.github/workflows/auth-jwt-publish.yml` | `haechi-auth-jwt` | `auth-jwt-v<semver>` | satellite publish, same signed-artifacts path |
|
|
71
74
|
| `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, same signed-artifacts path |
|
|
@@ -80,10 +83,16 @@ Satellites live under `satellites/*` in the npm workspaces monorepo and publish
|
|
|
80
83
|
|
|
81
84
|
**Per-satellite bootstrap order (first publish, no org needed):**
|
|
82
85
|
|
|
83
|
-
|
|
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.
|
|
86
|
+
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
87
|
|
|
86
|
-
|
|
88
|
+
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):
|
|
89
|
+
```bash
|
|
90
|
+
npm login --auth-type=web
|
|
91
|
+
cd satellites/<name> && npm publish --auth-type=web --provenance=false
|
|
92
|
+
```
|
|
93
|
+
`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`.
|
|
94
|
+
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`).
|
|
95
|
+
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
96
|
|
|
88
97
|
**Tag → workflow → package mapping:**
|
|
89
98
|
|
|
@@ -104,9 +113,9 @@ npm view haechi-crypto-kms --json # dist.attestations present; access "public"
|
|
|
104
113
|
|
|
105
114
|
**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
115
|
|
|
107
|
-
**0.9 satellites (new unscoped names
|
|
116
|
+
**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
117
|
|
|
109
|
-
**`haechi-ratelimit-redis` (
|
|
118
|
+
**`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
119
|
|
|
111
120
|
## 6. Deployment block conditions
|
|
112
121
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 신뢰성 하드닝 트랙 (Reliability Hardening Track)
|
|
2
2
|
|
|
3
|
-
- 상태:
|
|
3
|
+
- 상태: 출시 완료 — WS1–WS6 전부 core 1.2.0으로 전달·컷됨(릴리스 게이트 G7 Pass). 이 문서는 계획/감사 기록으로 보존합니다. (2026-06-12 확정; 1.1.1 코어에 대한 5-렌즈 읽기 전용 감사에 근거.)
|
|
4
4
|
- 대상 라인: 1.1.2(patch) → 1.2.0(minor); 신규 제품 표면 없음
|
|
5
5
|
- 목적: Haechi를 **상용 솔루션 수준의 신뢰성**으로 끌어올립니다 — 운영 AI 보안 게이트웨이에 기대되는 신뢰·운영성·탐지 품질의 밀도입니다. 이것은 품질 목표이지 상용화 계획이 아닙니다. 모든 항목은 **이미 존재하는 것을 조이거나, 측정하거나, 문서화**하며, 신규 기능을 추가하지 않습니다.
|
|
6
6
|
|