haechi 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +46 -11
- package/README.md +46 -11
- package/docs/current/config-version.ko.md +2 -2
- package/docs/current/config-version.md +2 -2
- package/docs/current/configuration.ko.md +26 -10
- package/docs/current/configuration.md +26 -10
- package/docs/current/operations-runbook.ko.md +36 -2
- package/docs/current/operations-runbook.md +39 -2
- package/docs/current/release-process.ko.md +5 -1
- package/docs/current/release-process.md +5 -1
- package/docs/current/risk-register-release-gate.ko.md +4 -3
- package/docs/current/risk-register-release-gate.md +4 -3
- package/docs/current/shared-responsibility.ko.md +2 -2
- package/docs/current/shared-responsibility.md +2 -2
- package/docs/current/threat-model.ko.md +4 -3
- package/docs/current/threat-model.md +4 -3
- package/examples/local-proxy-demo/README.md +51 -0
- package/examples/local-proxy-demo/demo.mjs +144 -0
- package/examples/local-proxy-demo/demo.tape +19 -0
- package/examples/local-proxy-demo/live-demo.mjs +121 -0
- package/examples/local-proxy-demo/live-demo.tape +25 -0
- package/haechi.config.example.json +2 -1
- package/package.json +3 -1
- package/packages/cli/bin/haechi.mjs +3 -2
- package/packages/cli/runtime.mjs +12 -1
- package/packages/filter/index.mjs +679 -6
- package/packages/privacy-profiles/index.mjs +72 -3
- package/packages/protocol-adapters/index.mjs +99 -1
- package/packages/proxy/index.mjs +7 -1
- package/packages/stream-filter/index.mjs +69 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Release Process
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document (core 1.
|
|
3
|
+
- 문서 상태: Living document (core 1.3.x 추적)
|
|
4
4
|
- 작성일: 2026-06-10
|
|
5
5
|
|
|
6
6
|
## 1. 로컬 릴리즈 검증
|
|
@@ -70,6 +70,7 @@ npm audit signatures
|
|
|
70
70
|
| `.github/workflows/auth-jwt-publish.yml` | `haechi-auth-jwt` | `auth-jwt-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
71
71
|
| `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
72
72
|
| `.github/workflows/auth-oidc-publish.yml` | `haechi-auth-oidc` | `auth-oidc-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
73
|
+
| `.github/workflows/ratelimit-redis-publish.yml` | `haechi-ratelimit-redis` | `ratelimit-redis-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
|
|
73
74
|
|
|
74
75
|
각 publish 워크플로는 `release: published`에서 트리거되지만 **가드**되어 둘이 교차 발화하지 않습니다. core job은 `v`로 시작하는 태그에서만 실행되고(그리고 `^v[0-9]+\.[0-9]+\.[0-9]+$` 재검증), satellite job은 `crypto-kms-v…`에서만 실행됩니다(그리고 `^crypto-kms-v[0-9]+\.[0-9]+\.[0-9]+$` 재검증 **및** 태그 버전이 satellite `package.json` 버전과 일치하는지 검증). npmjs.com Trusted Publisher는 각 패키지의 **특정 워크플로 파일명**에 바인딩됩니다 — 워크플로 파일 rename은 npm 설정을 갱신할 때까지 OIDC publish를 깨뜨립니다.
|
|
75
76
|
|
|
@@ -92,6 +93,7 @@ Satellite는 npm workspaces 모노레포의 `satellites/*`에 살며 core와 **
|
|
|
92
93
|
| `haechi-auth-jwt` | `auth-jwt-v<semver>` | `auth-jwt-publish.yml` | `satellites/auth-jwt/package.json` |
|
|
93
94
|
| `haechi-dashboard` | `dashboard-v<semver>` | `dashboard-publish.yml` | `satellites/dashboard/package.json` |
|
|
94
95
|
| `haechi-auth-oidc` | `auth-oidc-v<semver>` | `auth-oidc-publish.yml` | `satellites/auth-oidc/package.json` |
|
|
96
|
+
| `haechi-ratelimit-redis` | `ratelimit-redis-v<semver>` | `ratelimit-redis-publish.yml` | `satellites/ratelimit-redis/package.json` |
|
|
95
97
|
|
|
96
98
|
**satellite 릴리스 검증** (core와 동일한 신뢰 앵커):
|
|
97
99
|
|
|
@@ -104,6 +106,8 @@ npm view haechi-crypto-kms --json # dist.attestations 존재 확인; access "p
|
|
|
104
106
|
|
|
105
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 설정이 필요 없습니다.
|
|
106
108
|
|
|
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로 유지됩니다.
|
|
110
|
+
|
|
107
111
|
## 6. 배포 차단 조건
|
|
108
112
|
|
|
109
113
|
다음 중 하나라도 실패하면 npm publish를 하지 않습니다.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Release Process
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.3.x)
|
|
4
4
|
- Date: 2026-06-10
|
|
5
5
|
|
|
6
6
|
## 1. Local Release Verification
|
|
@@ -70,6 +70,7 @@ npm audit signatures
|
|
|
70
70
|
| `.github/workflows/auth-jwt-publish.yml` | `haechi-auth-jwt` | `auth-jwt-v<semver>` | satellite publish, same signed-artifacts path |
|
|
71
71
|
| `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, same signed-artifacts path |
|
|
72
72
|
| `.github/workflows/auth-oidc-publish.yml` | `haechi-auth-oidc` | `auth-oidc-v<semver>` | satellite publish, same signed-artifacts path |
|
|
73
|
+
| `.github/workflows/ratelimit-redis-publish.yml` | `haechi-ratelimit-redis` | `ratelimit-redis-v<semver>` | satellite publish, same signed-artifacts path |
|
|
73
74
|
|
|
74
75
|
Each publish workflow triggers on `release: published` but is **guarded** so the two never cross-fire: the core job runs only for tags starting `v` (and re-validates `^v[0-9]+\.[0-9]+\.[0-9]+$`); the satellite job runs only for `crypto-kms-v…` (and re-validates `^crypto-kms-v[0-9]+\.[0-9]+\.[0-9]+$` **and** that the tag version equals the satellite's `package.json` version). The npmjs.com Trusted Publisher for each package is bound to its **specific workflow filename** — renaming a workflow file breaks its OIDC publish until the npm config is updated.
|
|
75
76
|
|
|
@@ -92,6 +93,7 @@ No manual `npm publish` from a laptop is needed. Because the names are unscoped
|
|
|
92
93
|
| `haechi-auth-jwt` | `auth-jwt-v<semver>` | `auth-jwt-publish.yml` | `satellites/auth-jwt/package.json` |
|
|
93
94
|
| `haechi-dashboard` | `dashboard-v<semver>` | `dashboard-publish.yml` | `satellites/dashboard/package.json` |
|
|
94
95
|
| `haechi-auth-oidc` | `auth-oidc-v<semver>` | `auth-oidc-publish.yml` | `satellites/auth-oidc/package.json` |
|
|
96
|
+
| `haechi-ratelimit-redis` | `ratelimit-redis-v<semver>` | `ratelimit-redis-publish.yml` | `satellites/ratelimit-redis/package.json` |
|
|
95
97
|
|
|
96
98
|
**Verify a satellite release** (same anchors as core):
|
|
97
99
|
|
|
@@ -104,6 +106,8 @@ npm view haechi-crypto-kms --json # dist.attestations present; access "public"
|
|
|
104
106
|
|
|
105
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.
|
|
106
108
|
|
|
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.
|
|
110
|
+
|
|
107
111
|
## 6. Deployment block conditions
|
|
108
112
|
|
|
109
113
|
npm publish is not performed if any of the following fail.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Haechi 리스크 레지스터 및 릴리스 게이트
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document(core 1.
|
|
3
|
+
- 문서 상태: Living document(core 1.3.x 추적)
|
|
4
4
|
- 작성일: 2026-06-11
|
|
5
|
-
- 기준 버전: 1.
|
|
5
|
+
- 기준 버전: 1.3.x
|
|
6
6
|
- 기준 브랜치: `main`
|
|
7
7
|
|
|
8
8
|
## 1. 현재 판단
|
|
@@ -23,11 +23,12 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
|
|
|
23
23
|
| G0 | GitHub source 공개 | 테스트 통과, 보안 한계 문서화, 평문 audit leak 없음 | Pass |
|
|
24
24
|
| G1 | GitHub pre-release | P0 코드 리스크 해결, production-ready 표현 없음 | Pass |
|
|
25
25
|
| G2 | npm developer preview | P0 해결, preflight/SBOM/provenance 경로 준비, npm auth 확인 | Pass (`haechi@0.3.2` 2026-06-10 배포) |
|
|
26
|
-
| G3 | npm stable | P1 운영 reference, stream-aware enforcement, API stability 강화 |
|
|
26
|
+
| G3 | npm stable | P1 운영 reference, stream-aware enforcement, API stability 강화 | Pass (1.0.0 stable 컷에서 달성 — streaming inspection은 0.5, API freeze는 1.0.0에서 출시; G5 참조. G5–G7로 대체됨.) |
|
|
27
27
|
| G4 | 0.9.0 observability + interactive-auth 위성 컷 | P1-SEC-026 / P1-OPS-009 mitigated 및 P2-CRYPTO-001 accepted; `haechi-dashboard` + `haechi-auth-oidc` + `haechi-crypto-kms@0.2.0` 테스트 통과; 위성 tarball zero-dep; core 0.9.0 bump(추가적 FORBIDDEN_KEYS audit 강화만) | Pass |
|
|
28
28
|
| G5 | 1.0.0 stable API contract + signed-plugin sandbox | P1-SEC-024 / P1-SEC-025 mitigated, P2-API-001 / P2-OPS-006 resolved; API freeze + deprecation policy + `tests/api-contract.test.mjs` 통과; Ed25519 signed-plugin contract + `assertAuthProviderConformance` + worker-isolated `authProvider` sandbox 테스트 통과; PR0 위성 peer-range를 `>=0.8.0 <2.0.0`로 확대 및 `check-satellite-peer-ranges.mjs` preflight 게이트 통과; core는 zero runtime dependency 유지; core 1.0.0 bump | Pass |
|
|
29
29
|
| G6 | 1.1.0 plugin capability 강제 (`process-isolated`) | P1-SEC-027 / P1-SEC-028 mitigated; `process-isolated` 런타임(`--permission` 하 자식, 부여 0, `data:` URL 로드, stdio 무시, JSON-string IPC) + fail-closed `--allow-net` 기능 탐지(`netEnforcement:"require-permission"`) + 코어 `haechi/ssrf` 가드 + 호스트 중개 키 자료 + spawn-storm 서킷 브레이커; fs/net/stdio 레드팀 + SSRF + config 테스트 통과(행동 스위트는 `--allow-net` Node에서 실행, 아니면 fail-closed로 skip); API freeze 통과 유지(additive `./ssrf` export + additive config 키); core는 zero runtime dependency 유지; core 1.1.0 bump(additive + opt-in 마이너) | Pass |
|
|
30
30
|
| G7 | 1.2.0 신뢰성 강화 트랙 (WS1–WS6) | 탐지 품질 측정+강화(WS2: 라벨 코퍼스 precision/recall `bench:detection` 게이트, 자격증명+국제 PII 커버리지, 하드블록 타입 불변식이 적용된 `filters.minConfidence` / `filters.allowlist`, offset 무결성을 갖춘 NFKC 유니코드 회피 폴딩); WS3 주입 가능한 `rateLimiter` 시임 + bounded fixed-window map; WS4 운영성(`/__haechi/live`+`/ready` 분리, 주입 가능한 `/metrics`, 구조적 로그 + 요청별 `correlationId`, graceful drain, max-in-flight backpressure, env overlay, 하드닝 Dockerfile/compose/runbook, `configVersion`); WS6 proxy TLS / remote-bind 하드닝(`proxy.tls` / `proxy.trustForwardedProto`, fail-closed `assertSafeProxyTransport`) + OWASP-LLM/NIST 컨트롤 매핑 백서 + RFC 9116 `security.txt` + 취약점 공개 경로. 모든 변경은 1.1 동작을 보존하는 기본값 뒤의 additive(`tests/api-contract.test.mjs` 통과); no-plaintext-in-audit 불변식이 텔레메트리까지 확장; core는 zero runtime dependency 유지; core 1.2.0 bump(additive 마이너) | Pass |
|
|
31
|
+
| G8 | 1.3.0 백엔드 + 탐지 커버리지 확장 | **Anthropic Messages API**(`/v1/messages`, content-block + SSE `delta.text`, `event:` 라인 보존 재직렬화)와 **Google Gemini API**(model-in-path `:generateContent`/`:streamGenerateContent`, 기존 정확-매칭 어댑터를 바이트 동일하게 두는 additive `:method`-suffix 라우트 매처) 프로토콜 어댑터 추가; 탐지 커버리지 확장 — 클라우드/SaaS provider 키(OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored)와 국제 PII(FR/ES/JP + IT/SG/IN/DE/NL 국가 ID, 체크섬 validator), 각 하드블록-대-dial-eligible 결정은 측정된 충돌률 기반(하드블록은 비숫자 앵커 또는 비현실적으로 드문 형태가 필요; 흔한 길이의 bare-digit run은 allowlist로 정리 가능 유지); `bench:throughput` proxy 부하 벤치; `haechi-ratelimit-redis` 공유 저장소 rate-limiter 위성(WS3 시임의 운영 소비자; proxy가 이제 `rateLimiter.allow`를 `await`); `haechi-dashboard`가 요청별 `correlationId` 노출. 모든 변경은 additive — 새 `target.type`/탐지타입/`privacy.profile` *값*이며 새 config 키가 아님(`configVersion`은 `1` 유지); `tests/api-contract.test.mjs` 통과; core는 zero runtime dependency 유지; core 1.3.0 bump(additive 마이너) | Pass |
|
|
31
32
|
|
|
32
33
|
## 3. P0 배포 차단 리스크 상태
|
|
33
34
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Haechi Risk Register and Release Gates
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.3.x)
|
|
4
4
|
- Date: 2026-06-11
|
|
5
|
-
- Target version: 1.
|
|
5
|
+
- Target version: 1.3.x
|
|
6
6
|
- Branch: `main`
|
|
7
7
|
|
|
8
8
|
## 1. Current Assessment
|
|
@@ -23,11 +23,12 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
23
23
|
| G0 | GitHub source publication | Tests pass, security limitations documented, no plaintext audit leak | Pass |
|
|
24
24
|
| G1 | GitHub pre-release | P0 code risks resolved, no production-ready language | Pass |
|
|
25
25
|
| G2 | npm developer preview | P0 resolved, preflight/SBOM/provenance paths ready, npm auth confirmed | Pass (`haechi@0.3.2` published 2026-06-10) |
|
|
26
|
-
| G3 | npm stable | P1 production reference, stream-aware enforcement, API stability hardened |
|
|
26
|
+
| G3 | npm stable | P1 production reference, stream-aware enforcement, API stability hardened | Pass (achieved at the 1.0.0 stable cut — streaming inspection shipped in 0.5, the API freeze in 1.0.0; see G5. Superseded by G5–G7.) |
|
|
27
27
|
| G4 | 0.9.0 observability + interactive-auth satellite cut | P1-SEC-026 / P1-OPS-009 mitigated and P2-CRYPTO-001 accepted; `haechi-dashboard` + `haechi-auth-oidc` + `haechi-crypto-kms@0.2.0` tests green; satellite tarballs zero-dep; core bumped to 0.9.0 (only an additive FORBIDDEN_KEYS audit hardening) | Pass |
|
|
28
28
|
| G5 | 1.0.0 stable API contract + signed-plugin sandbox | P1-SEC-024 / P1-SEC-025 mitigated, P2-API-001 / P2-OPS-006 resolved; the API freeze + deprecation policy + `tests/api-contract.test.mjs` green; the Ed25519 signed-plugin contract + `assertAuthProviderConformance` + the worker-isolated `authProvider` sandbox tests green; PR0 satellite peer-ranges widened to `>=0.8.0 <2.0.0` and the `check-satellite-peer-ranges.mjs` preflight gate green; core stays zero runtime dependency; core bumped to 1.0.0 | Pass |
|
|
29
29
|
| G6 | 1.1.0 plugin capability enforcement (`process-isolated`) | P1-SEC-027 / P1-SEC-028 mitigated; the `process-isolated` runtime (child under `--permission`, zero grants, `data:`-URL load, stdio-ignored, JSON-string IPC) + the fail-closed `--allow-net` feature detection (`netEnforcement:"require-permission"`) + the core `haechi/ssrf` guard + host-mediated key material + the spawn-storm circuit breaker; the fs/net/stdio red-team + SSRF + config tests green (the behavioral suite runs on a `--allow-net` Node and skips fail-closed otherwise); the API freeze stays green (additive `./ssrf` export + additive config keys); core stays zero runtime dependency; core bumped to 1.1.0 (additive + opt-in minor) | Pass |
|
|
30
30
|
| G7 | 1.2.0 Reliability Hardening Track (WS1–WS6) | Detection quality measured + tightened (WS2: a labeled-corpus precision/recall `bench:detection` gate, credential + international-PII coverage, `filters.minConfidence` / `filters.allowlist` with the hard-block-types invariant, NFKC unicode-evasion folding with offset-integrity); WS3 injectable `rateLimiter` seam + bounded fixed-window map; WS4 operability (`/__haechi/live`+`/ready` split, injectable `/metrics`, structured logs + per-request `correlationId`, graceful drain, max-in-flight backpressure, env overlay, hardened Dockerfile/compose/runbook, `configVersion`); WS6 proxy TLS / remote-bind hardening (`proxy.tls` / `proxy.trustForwardedProto`, fail-closed `assertSafeProxyTransport`) + OWASP-LLM/NIST control-mapping whitepaper + RFC 9116 `security.txt` + vulnerability-disclosure path. Every change is additive behind 1.1-preserving defaults (`tests/api-contract.test.mjs` green); the no-plaintext-in-audit invariant extends to telemetry; core stays zero runtime dependency; core bumped to 1.2.0 (additive minor) | Pass |
|
|
31
|
+
| G8 | 1.3.0 backend + detection coverage expansion | New protocol adapters for the **Anthropic Messages API** (`/v1/messages`, content-block + SSE `delta.text` with `event:`-line-preserving re-serialize) and the **Google Gemini API** (model-in-path `:generateContent`/`:streamGenerateContent` via an additive `:method`-suffix route matcher that leaves the exact-match adapters byte-identical); detection coverage expansion — cloud/SaaS provider keys (OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored) and international PII (FR/ES/JP + IT/SG/IN/DE/NL national IDs with checksum validators), each hard-block-vs-dial-eligible decision driven by measured collision rates (a non-numeric anchor or implausibly-rare shape is required for hard-block; a bare-digit run over a common length stays allowlist-clearable); a `bench:throughput` proxy load benchmark; the `haechi-ratelimit-redis` shared-store rate-limiter satellite (the WS3 seam's production consumer; the proxy now `await`s `rateLimiter.allow`); `haechi-dashboard` surfaces the per-request `correlationId`. Every change is additive — new `target.type`/detection-type/`privacy.profile` *values*, not new config keys (`configVersion` stays `1`); `tests/api-contract.test.mjs` green; core stays zero runtime dependency; core bumped to 1.3.0 (additive minor) | Pass |
|
|
31
32
|
|
|
32
33
|
## 3. P0 Distribution-Blocking Risk Status
|
|
33
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Shared Responsibility
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document (core 1.
|
|
3
|
+
- 문서 상태: Living document (core 1.3.x 추적)
|
|
4
4
|
- 작성일: 2026-06-10
|
|
5
5
|
|
|
6
6
|
## 1. 책임 매트릭스
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
|
|
42
42
|
Haechi의 상태 보유 통제는 설계상 단일 프로세스입니다. 로드밸런서 뒤에서 복제본을 2개 이상 실행하면, 운영자가 공유 인프라를 제공하지 않는 한 이들이 **무음으로 약화**됩니다.
|
|
43
43
|
|
|
44
|
-
- **Rate limit**은 프로세스별·인메모리이므로 전체 처리량이 복제본 수만큼 배가됩니다. identity별 한도를 공유 front door에서 강제하거나, `createRuntime(config, { rateLimiter })`를 통해 공유 저장소 기반 `rateLimiter`를 주입하세요(이 시임은 `allow(key, limit)` 계약을
|
|
44
|
+
- **Rate limit**은 프로세스별·인메모리이므로 전체 처리량이 복제본 수만큼 배가됩니다. identity별 한도를 공유 front door에서 강제하거나, `createRuntime(config, { rateLimiter })`를 통해 공유 저장소 기반 `rateLimiter`를 주입하세요(이 시임은 `allow(key, limit)` 계약을 만족하며, `boolean` 또는 `Promise<boolean>`을 반환할 수 있습니다. [`configuration.md` → Rate limiter 주입](./configuration.ko.md#rate-limiter-주입) 참고). [`haechi-ratelimit-redis`](https://github.com/raeseoklee/haechi/tree/main/satellites/ratelimit-redis) satellite가 레퍼런스 공유 저장소(Redis 기반) 구현입니다 — 주입된 클라이언트 위의 fixed-window 카운터입니다. 기본 프로세스별 limiter는 window map도 bounding하므로 identity 기준 무한 메모리 증가가 없습니다.
|
|
45
45
|
- **Audit hash chain + anchor**는 단일 작성자입니다. 각 복제본에 **고유한** `audit.path`(및 anchor 경로)를 주세요. 하나의 audit 파일을 복제본 간에 공유하면 체인이 분기되어 검증 불가 상태가 됩니다.
|
|
46
46
|
- **TokenVault와 auth store**는 whole-file 로컬 저장소입니다 — 단일 호스트에서는 올바르지만 공유 다중 작성자 저장소는 아닙니다. 다중 복제 토큰화에는 공유 `tokenVault`를 주입하세요.
|
|
47
47
|
- 파일 락은 `O_EXCL` + atomic rename에 의존하며 NFS/공유 파일시스템에서는 보장되지 않습니다 — 이 저장소들은 로컬 디스크에 두세요.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Shared Responsibility
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.3.x)
|
|
4
4
|
- Date: 2026-06-10
|
|
5
5
|
|
|
6
6
|
## 1. Responsibility Matrix
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
|
|
42
42
|
Haechi's stateful controls are single-process by design. Running 2+ replicas behind a load balancer **silently weakens** them unless the operator supplies shared infrastructure:
|
|
43
43
|
|
|
44
|
-
- **Rate limit** is per-process and in-memory — total throughput multiplies by the replica count. Enforce a per-identity limit at a shared front door, or inject a shared-store `rateLimiter` via `createRuntime(config, { rateLimiter })` (the seam satisfies the `allow(key, limit)` contract
|
|
44
|
+
- **Rate limit** is per-process and in-memory — total throughput multiplies by the replica count. Enforce a per-identity limit at a shared front door, or inject a shared-store `rateLimiter` via `createRuntime(config, { rateLimiter })` (the seam satisfies the `allow(key, limit)` contract, which may return `boolean` or `Promise<boolean>`; see [`configuration.md` → Rate limiter injection](./configuration.md#rate-limiter-injection)). The [`haechi-ratelimit-redis`](https://github.com/raeseoklee/haechi/tree/main/satellites/ratelimit-redis) satellite is the reference shared-store (Redis-backed) implementation — a fixed-window counter over an injected client. The default per-process limiter also bounds its window map (no unbounded memory growth keyed by identity).
|
|
45
45
|
- **Audit hash chain + anchor** are single-writer. Give each replica its **own** `audit.path` (and anchor path); never share one audit file across replicas, or the chain forks into an unverifiable state.
|
|
46
46
|
- **TokenVault and the auth store** are whole-file local stores — correct for one host, but not a shared multi-writer store. For multi-replica tokenization, inject a shared `tokenVault`.
|
|
47
47
|
- File locking relies on `O_EXCL` + atomic rename, which do not hold on NFS / shared filesystems — keep these stores on local disk.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Threat Model
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document(core 1.
|
|
3
|
+
- 문서 상태: Living document(core 1.3.x 추적)
|
|
4
4
|
- 작성일: 2026-06-10
|
|
5
5
|
|
|
6
6
|
## 1. 보호 대상
|
|
@@ -46,7 +46,8 @@ Haechi가 보호하려는 주요 자산은 다음과 같습니다.
|
|
|
46
46
|
| 행 걸린 upstream | proxy 연결 고갈 | `limits.upstreamTimeoutMs` 기본 120s, 초과 시 504 fail |
|
|
47
47
|
| signing/encryption 키 혼용 | key separation 위반 | policy bundle 서명 키를 domain-separated 파생 키로 분리 |
|
|
48
48
|
| JSON number/object key 은닉 | 카드번호 등 비문자열 leaf 미탐지 | number leaf와 object key도 detection/transform 대상 |
|
|
49
|
-
| 모든 정규식 규칙을 우회하는 유니코드 난독화 | card/RRN/phone/email/secret을 시각·의미상 동등한 비ASCII 유니코드 형태(전각 숫자 `4242…`, 전각 `@`, 수학·원문자 영숫자)로 보내 모든 탐지 규칙을 무력화 | **매칭 전 각 string leaf의 NFKC 정규화**(WS2d)입니다. 정규화가 무변환인 경우(leaf의 약 99%) 탐지는 이전과 바이트 단위로 동일합니다. 접힘이 **위치 안정적**인 경우(모든 코드포인트가 같은 UTF-16 길이로 접히고 코드포인트별 접힘이 전체 정규화를 그대로 재구성) 정규화 사본에서 탐지하고 원본의 정확한 구간을 redact/block하며, 기록되는 값은 접힌 형태가 아니라 원본 바이트입니다. 그 외 — 길이가 달라지거나(수학 숫자·합자) 총 길이는 같지만 내부 offset을 이동시키는 수축+확장 보상 — 의 경우 offset을 원본에 매핑할 수 없으므로 탐지가 **fail closed**되어 leaf 전체를 덮는 단일 탐지로 처리합니다(leaf 전체 redact/block — 우회 시도를 과도 redact하는 것이 안전한 실패입니다). `String.prototype.normalize` 빌트인을 사용하므로 새 의존성은 없습니다.
|
|
49
|
+
| 모든 정규식 규칙을 우회하는 유니코드 난독화 | card/RRN/phone/email/secret을 시각·의미상 동등한 비ASCII 유니코드 형태(전각 숫자 `4242…`, 전각 `@`, 수학·원문자 영숫자)로 보내 모든 탐지 규칙을 무력화 | **매칭 전 각 string leaf의 NFKC 정규화**(WS2d)입니다. 정규화가 무변환인 경우(leaf의 약 99%) 탐지는 이전과 바이트 단위로 동일합니다. 접힘이 **위치 안정적**인 경우(모든 코드포인트가 같은 UTF-16 길이로 접히고 코드포인트별 접힘이 전체 정규화를 그대로 재구성) 정규화 사본에서 탐지하고 원본의 정확한 구간을 redact/block하며, 기록되는 값은 접힌 형태가 아니라 원본 바이트입니다. 그 외 — 길이가 달라지거나(수학 숫자·합자) 총 길이는 같지만 내부 offset을 이동시키는 수축+확장 보상 — 의 경우 offset을 원본에 매핑할 수 없으므로 탐지가 **fail closed**되어 leaf 전체를 덮는 단일 탐지로 처리합니다(leaf 전체 redact/block — 우회 시도를 과도 redact하는 것이 안전한 실패입니다). `String.prototype.normalize` 빌트인을 사용하므로 새 의존성은 없습니다. **잔여는 이제 opt-in 통제입니다:** base64/percent-encoded 페이로드는 `filters.decodeAndRescan`이 활성화된 경우에만 디코딩 후 재검사합니다(다음 행 및 §4 참조) |
|
|
50
|
+
| 모든 정규식 규칙을 우회하는 base64/percent-encoded 페이로드 | 전송 전 base64·percent로 인코딩된 card/RRN/secret은 모든 규칙을 통과합니다(Haechi는 NFKC 텍스트에서 매칭하지만 디코딩하지 않습니다) | **opt-in `filters.decodeAndRescan`**입니다(기본 OFF → 이전과 바이트 단위로 동일). ON일 때, 일반 NFKC 스캔 이후 base64/base64url로 **보이는** string leaf(고정 알파벳, 유효한 길이, `16…8192` 바이트 범위, 같은 leaf로 round-trip, `node:buffer` `isUtf8`로 **유효한 UTF-8** 디코딩)이거나 `%XX` 이스케이프를 포함하는 leaf(try/catch 안의 `decodeURIComponent`)를 디코딩하여 같은 규칙·validator로 재검사합니다. **offset 처리는 fail closed입니다:** 디코딩된 매칭은 인코딩된 leaf에 유효한 offset이 없으므로, 원본 인코딩 leaf 전체를 덮는 **WHOLE-LEAF** 탐지(`start:0, end:leaf.length`)를 발생시킵니다 — transform이 leaf 전체를 redact/block하며, 디코딩된 offset을 원본으로 되돌려 매핑하지 않습니다. **정밀도 가드:** 디코딩된 매칭은 **validator 기반이거나 하드 블록 타입**일 때만 발생합니다(Luhn 통과 `card`, 체크섬 `kr_rrn`/`us_ssn`, IBAN mod-97, 또는 앵커된 규칙의 `secret`/`api_key`). validator 없는 디코딩된 소프트 타입 매칭(맨 전화번호 형태 등)은 발생하지 **않으므로** 무작위 base64는 오탐하지 않습니다. 새 의존성은 없습니다(`node:buffer` Buffer + `decodeURIComponent` 빌트인). **수용된 잔여:** Haechi가 디코딩하지 않는 인코딩(gzip, hex, 중첩/이중 인코딩, 커스텀 알파벳), 그리고 양성 텍스트 안에 Luhn-유효 16자리 런으로 디코딩되도록 의도적으로 조작된 평문(이에 발생하는 것은 오탐이 아니라 올바른 동작) |
|
|
50
51
|
| 인증 없는 멀티 클라이언트 접근 | 로컬 프로세스가 upstream / token round-trip 경로를 무단 사용 | 선택적 bearer auth (`auth.provider: bearer`); 없거나 잘못된 경우 → 바디 읽기 전 401; identity별 rate limit 및 model allowlist |
|
|
51
52
|
| Audit tail truncation | 꼬리 audit 레코드의 무음 삭제 | 추가 전용/별도 미디어의 `audit.anchor` head-hash anchoring으로 마지막 anchor까지의 절단 탐지 (0.7) |
|
|
52
53
|
| Local dev key in production | 소프트웨어 키의 운영 custody 오용 | `assertCryptoProviderConformance`를 통한 외부 `cryptoProvider` 주입; reference KMS adapter (envelope 암호화) |
|
|
@@ -87,7 +88,7 @@ Haechi는 다음을 보장하지 않습니다.
|
|
|
87
88
|
- 법적 컴플라이언스 인증
|
|
88
89
|
- 모델 hallucination, prompt injection 완전 방어
|
|
89
90
|
- 외부 MCP server의 OAuth/resource binding 검증
|
|
90
|
-
- base64/percent-encoded 값의
|
|
91
|
+
- base64/percent-encoded 값의 **기본** 디코딩 후 검사 — Haechi는 NFKC 정규화 텍스트에서 매칭하며(§3의 유니코드 난독화 행 참조) opt-in `filters.decodeAndRescan`(기본 OFF)을 활성화하지 않는 한 base64/URL 디코딩 후 재검사는 하지 **않습니다**. OFF이면 전송 전 base64·percent로 인코딩된 값은 검사되지 않습니다. ON이면 §3에 설명된 정밀도 가드(validator 기반 / 하드 블록 매칭만, WHOLE-LEAF fail-closed)와 함께 디코딩-후-재검사 패스가 동작합니다. WS2d는 *상시* 디코딩을 보류했고(오탐이 많고 범위 내에서 precision-neutral하지 않음), opt-in 통제는 트레이드오프를 수용하는 운영자를 위해 그 잔여를 닫습니다. 다른 인코딩(gzip/hex/중첩/커스텀 알파벳)은 여전히 범위 밖입니다.
|
|
91
92
|
- URL query string 내 민감값 검사 (JSON body만 검사)
|
|
92
93
|
- 마지막 anchor 이후의 audit tail truncation — `audit.anchor`(0.7)는 anchor가 추가 전용/별도 미디어에 있을 때 마지막 anchor까지의 레코드 삭제를 탐지합니다. 마지막 anchor 이후 기록된 레코드와 동일 파일시스템 anchor는 대상에서 제외됩니다
|
|
93
94
|
- JSON-RPC batch 메시지 처리 (MCP stdio filter는 batch를 fail-closed로 거부)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Threat Model
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.3.x)
|
|
4
4
|
- Date: 2026-06-10
|
|
5
5
|
|
|
6
6
|
## 1. Assets Under Protection
|
|
@@ -46,7 +46,8 @@ The primary assets Haechi protects are:
|
|
|
46
46
|
| Hung upstream | Proxy connection exhaustion | `limits.upstreamTimeoutMs` default 120 s; 504 fail on timeout |
|
|
47
47
|
| Signing/encryption key conflation | Key separation violation | Policy bundle signing key isolated as a domain-separated derived key |
|
|
48
48
|
| JSON number / object key concealment | Undetected non-string leaves such as card numbers | Number leaves and object keys included in detection/transform scope |
|
|
49
|
-
| Unicode-obfuscation evasion of every regex rule | A card/RRN/phone/email/secret sent in a visually/semantically equivalent non-ASCII Unicode form (full-width digits `4242…`, full-width `@`, mathematical/enclosed alphanumerics) defeats every detection rule | **NFKC normalization of each string leaf before matching** (WS2d). When the normalization is a no-op (~99% of leaves) detection is byte-identical to before. When the fold is **position-stable** (every codepoint folds to the same UTF-16 length and the per-codepoint folds reconstruct the whole normalization), detection runs on the normalized copy and the exact original span is redacted/blocked (the recorded value is the original bytes, never the fold). Otherwise — a length change (mathematical digits/ligatures) **or** a compensating contraction+expansion that keeps the total length equal while shifting interior offsets — offsets cannot map back, so detection **fails closed** to a single whole-leaf detection (the entire leaf is redacted/blocked — over-redacting an evasion attempt is the safe failure). Uses the `String.prototype.normalize` builtin (no new dependency). **
|
|
49
|
+
| Unicode-obfuscation evasion of every regex rule | A card/RRN/phone/email/secret sent in a visually/semantically equivalent non-ASCII Unicode form (full-width digits `4242…`, full-width `@`, mathematical/enclosed alphanumerics) defeats every detection rule | **NFKC normalization of each string leaf before matching** (WS2d). When the normalization is a no-op (~99% of leaves) detection is byte-identical to before. When the fold is **position-stable** (every codepoint folds to the same UTF-16 length and the per-codepoint folds reconstruct the whole normalization), detection runs on the normalized copy and the exact original span is redacted/blocked (the recorded value is the original bytes, never the fold). Otherwise — a length change (mathematical digits/ligatures) **or** a compensating contraction+expansion that keeps the total length equal while shifting interior offsets — offsets cannot map back, so detection **fails closed** to a single whole-leaf detection (the entire leaf is redacted/blocked — over-redacting an evasion attempt is the safe failure). Uses the `String.prototype.normalize` builtin (no new dependency). **Residual now an opt-in control:** base64/percent-encoded payloads are decoded-and-rescanned only when `filters.decodeAndRescan` is enabled (see the next row and §4) |
|
|
50
|
+
| Base64/percent-encoded payload evades every regex rule | A card/RRN/secret base64- or percent-encoded before sending passes every rule (Haechi matches the NFKC text but does not decode) | **Opt-in `filters.decodeAndRescan`** (default OFF → byte-identical to before). When ON, after the normal NFKC scan a string leaf that LOOKS base64/base64url (anchored alphabet, valid length, within `16…8192` bytes, round-trips to the same leaf, decodes to **valid UTF-8** via `node:buffer` `isUtf8`) or contains a `%XX` escape (`decodeURIComponent` in try/catch) is decoded and rescanned with the same rules + validators. **Offset handling fails closed:** a decoded hit has no offset in the encoded leaf, so it emits a **WHOLE-LEAF** detection of the original encoded leaf (`start:0, end:leaf.length`) — the transform redacts/blocks the entire leaf; a decoded offset is never mapped back. **Precision guard:** a decoded hit only fires when it is **validator-backed or a hard-block type** (a Luhn-passing `card`, a checksum `kr_rrn`/`us_ssn`, an IBAN mod-97, or a `secret`/`api_key` on its anchored rule). A decoded soft-type-without-validator match (a bare phone-shaped run) does **not** fire, so random base64 does not false-positive. Zero new dependency (`node:buffer` Buffer + the `decodeURIComponent` builtin). **Accepted residual:** an encoding Haechi does not decode (gzip, hex, nested/double-encoding, a custom alphabet), and a deliberately contrived plaintext that decodes to a Luhn-valid 16-digit run inside benign text (firing on it is correct, not a false positive) |
|
|
50
51
|
| Unauthenticated multi-client access | Any local process uses the upstream / token round-trip | Optional bearer auth (`auth.provider: bearer`); missing/invalid → 401 before body read; per-identity rate limit and model allowlist |
|
|
51
52
|
| Audit tail truncation | Silent deletion of trailing audit records | `audit.anchor` head-hash anchoring on append-only/separate media detects truncation back to the last anchor (0.7) |
|
|
52
53
|
| Local dev key in production | Software key misused as production custody | External `cryptoProvider` injection with `assertCryptoProviderConformance`; reference KMS adapter (envelope encryption) |
|
|
@@ -87,7 +88,7 @@ Haechi does not guarantee:
|
|
|
87
88
|
- Legal compliance certification
|
|
88
89
|
- Complete defense against model hallucination or prompt injection
|
|
89
90
|
- OAuth/resource binding validation for external MCP servers
|
|
90
|
-
- Inspection of base64/percent-encoded values **after decoding** — Haechi matches on the NFKC-normalized text (see the Unicode-evasion row in §3)
|
|
91
|
+
- Inspection of base64/percent-encoded values **after decoding** **by default** — Haechi matches on the NFKC-normalized text (see the Unicode-evasion row in §3) and does **not** base64/URL-decode-and-rescan unless the opt-in `filters.decodeAndRescan` is enabled (default OFF). With it OFF, a value that is base64- or percent-encoded before sending is not inspected. With it ON, the decode-and-rescan pass runs with the precision guard described in §3 (validator-backed / hard-block hits only, whole-leaf fail-closed). WS2d deferred an *always-on* decode (false-positive-prone, not precision-neutral within scope); the opt-in control closes that residual for operators who accept the trade-off, and other encodings (gzip/hex/nested/custom-alphabet) remain out of scope.
|
|
91
92
|
- Detection of sensitive values in URL query strings (JSON body only)
|
|
92
93
|
- Audit tail truncation beyond the last anchor — `audit.anchor` (0.7) detects deletion of records back to the last anchor when the anchor is on append-only/separate media; records written after the last anchor, and same-filesystem anchors, are not covered
|
|
93
94
|
- JSON-RPC batch message processing (the MCP stdio filter rejects batches fail-closed)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Local end-to-end demo
|
|
2
|
+
|
|
3
|
+
A self-contained, **reproducible** walkthrough of Haechi — no remote model required.
|
|
4
|
+
It stands up a tiny OpenAI-compatible *stub* upstream and the **real** Haechi proxy
|
|
5
|
+
in front of it (in `enforce` mode), then narrates what happens to a payload carrying
|
|
6
|
+
an email, a phone number, an API key, and a card number.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
node examples/local-proxy-demo/demo.mjs
|
|
10
|
+
# or, from the repo root:
|
|
11
|
+
npm run demo
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
What it shows, in order:
|
|
15
|
+
|
|
16
|
+
1. **The model only sees protected values** — the proxy detects and transforms the
|
|
17
|
+
payload *before* forwarding, so the stub (standing in for the model) receives
|
|
18
|
+
`[TOKEN:…]` for the email, a masked phone, and `[REDACTED:api_key]` for the key.
|
|
19
|
+
2. **The token round-trip** — because the email was *tokenized* (reversible), the
|
|
20
|
+
caller gets `minji.kim@example.com` back, while the masked phone and redacted
|
|
21
|
+
secret stay protected. The model's own leaked secret in its reply is
|
|
22
|
+
response-protected too.
|
|
23
|
+
3. **The audit log** carries detection metadata and is hash-chained — and never any
|
|
24
|
+
plaintext email/phone/key.
|
|
25
|
+
4. **Day-2 operability** — the live `/__haechi/ready` readiness probe and the
|
|
26
|
+
Prometheus `/__haechi/metrics` surface.
|
|
27
|
+
5. **A card number is blocked outright** (`403`, fail-closed) — it never reaches the
|
|
28
|
+
model.
|
|
29
|
+
|
|
30
|
+
Zero dependencies (only `node:` builtins + the in-repo `haechi` packages). The demo
|
|
31
|
+
is programmatic for reproducibility; for the real CLI invocation see the
|
|
32
|
+
[Quickstart](../../README.md#quickstart) and
|
|
33
|
+
[`docs/current/configuration.md`](../../docs/current/configuration.md).
|
|
34
|
+
|
|
35
|
+
## Live demo against a real model
|
|
36
|
+
|
|
37
|
+
`live-demo.mjs` runs the same flow against a **real** upstream (vLLM / Ollama / any
|
|
38
|
+
OpenAI-compatible server) instead of the stub. It asks the model to repeat the phone
|
|
39
|
+
number it was given — and the model can only return the *masked* form, because the
|
|
40
|
+
real number never reached it. This is the run recorded in the README GIF
|
|
41
|
+
(`demo.tape` records the stub demo; `live-demo.tape` records this one).
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
HAECHI_LIVE_UPSTREAM=http://127.0.0.1:8000 \
|
|
45
|
+
HAECHI_LIVE_MODEL="Qwen/Qwen3.6-35B-A3B-FP8" \
|
|
46
|
+
node examples/local-proxy-demo/live-demo.mjs
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`HAECHI_LIVE_TYPE` (default `vllm-openai`) and `HAECHI_LIVE_MODEL` override the target.
|
|
50
|
+
For Qwen3-style reasoning servers the request sets `chat_template_kwargs.enable_thinking
|
|
51
|
+
= false` so the reply is a terse line; non-reasoning servers ignore it.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Self-contained, reproducible Haechi demo — no remote model required.
|
|
3
|
+
//
|
|
4
|
+
// It stands up a tiny OpenAI-compatible *stub* upstream and the REAL Haechi proxy
|
|
5
|
+
// in front of it, then walks through what Haechi does to a payload that carries an
|
|
6
|
+
// email, a phone number, an API key, and a card:
|
|
7
|
+
// 1. the model only ever sees redacted/tokenized values (proven by echoing the
|
|
8
|
+
// exact body the stub received),
|
|
9
|
+
// 2. the caller gets the original email back (the token round-trip),
|
|
10
|
+
// 3. the audit log carries no plaintext,
|
|
11
|
+
// 4. the live /__haechi/metrics + /__haechi/ready operability surface,
|
|
12
|
+
// 5. a card is blocked outright (fail-closed).
|
|
13
|
+
//
|
|
14
|
+
// Run: node examples/local-proxy-demo/demo.mjs (or: npm run demo)
|
|
15
|
+
// Zero dependencies — only node: builtins and the in-repo haechi packages.
|
|
16
|
+
|
|
17
|
+
import { createServer } from "node:http";
|
|
18
|
+
import { mkdtemp, readFile } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
import { createRuntime } from "../../packages/cli/runtime.mjs";
|
|
23
|
+
import { createHaechiProxy } from "../../packages/proxy/index.mjs";
|
|
24
|
+
import { initLocalKeyFile } from "../../packages/crypto/index.mjs";
|
|
25
|
+
|
|
26
|
+
const B = "\x1b[1m", D = "\x1b[2m", G = "\x1b[32m", Y = "\x1b[33m", C = "\x1b[36m", R = "\x1b[31m", X = "\x1b[0m";
|
|
27
|
+
const rule = () => console.log(D + "─".repeat(64) + X);
|
|
28
|
+
const scene = (n, t) => { console.log(); rule(); console.log(`${B}${C} ${n}. ${t}${X}`); rule(); };
|
|
29
|
+
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
|
|
31
|
+
// A minimal OpenAI-compatible stub. It records the EXACT body it receives (which is
|
|
32
|
+
// whatever the proxy forwarded, i.e. the protected payload) and replies with a
|
|
33
|
+
// canned assistant message that itself leaks a secret, to exercise response protection.
|
|
34
|
+
function startStubUpstream() {
|
|
35
|
+
let lastReceived = null;
|
|
36
|
+
const server = createServer((req, res) => {
|
|
37
|
+
let body = "";
|
|
38
|
+
req.on("data", (c) => (body += c));
|
|
39
|
+
req.on("end", () => {
|
|
40
|
+
lastReceived = body;
|
|
41
|
+
// Echo the (already-protected) user content back so the response exercises the
|
|
42
|
+
// token round-trip, and append a leaked secret so response protection fires.
|
|
43
|
+
let echoed = "";
|
|
44
|
+
try { echoed = JSON.parse(body).messages.at(-1).content; } catch { /* ignore */ }
|
|
45
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
46
|
+
res.end(JSON.stringify({
|
|
47
|
+
id: "chatcmpl-demo",
|
|
48
|
+
object: "chat.completion",
|
|
49
|
+
choices: [{ index: 0, message: { role: "assistant",
|
|
50
|
+
content: `Noted — I will follow up. You wrote: "${echoed}" (our ref: token=DEMOleak9876543210notRealzyxwvu)` } }]
|
|
51
|
+
}));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
server.listen(0, "127.0.0.1", () => resolve({ server, url: `http://127.0.0.1:${server.address().port}`, received: () => lastReceived }));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function main() {
|
|
60
|
+
console.log(`\n${B}🛡 Haechi — local end-to-end demo${X} ${D}(stub upstream, real proxy, enforce mode)${X}`);
|
|
61
|
+
|
|
62
|
+
const dir = await mkdtemp(join(tmpdir(), "haechi-demo-"));
|
|
63
|
+
const keyFile = join(dir, ".haechi", "dev.keys.json");
|
|
64
|
+
const auditPath = join(dir, ".haechi", "audit.jsonl");
|
|
65
|
+
await initLocalKeyFile(keyFile, { force: true });
|
|
66
|
+
const stub = await startStubUpstream();
|
|
67
|
+
|
|
68
|
+
const runtime = createRuntime({
|
|
69
|
+
mode: "enforce",
|
|
70
|
+
target: { type: "openai-compatible", upstream: stub.url },
|
|
71
|
+
policy: {
|
|
72
|
+
mode: "enforce",
|
|
73
|
+
presets: ["llm-redact"],
|
|
74
|
+
actions: { email: "tokenize", phone: "mask", secret: "redact", api_key: "redact", card: "block" }
|
|
75
|
+
},
|
|
76
|
+
tokenVault: { detokenizeResponses: true },
|
|
77
|
+
responseProtection: { enabled: true, mode: "enforce", failureMode: "fail-closed" },
|
|
78
|
+
keys: { keyFile },
|
|
79
|
+
audit: { path: auditPath }
|
|
80
|
+
});
|
|
81
|
+
const proxy = createHaechiProxy({ runtime, port: 0 });
|
|
82
|
+
const addr = await proxy.listen();
|
|
83
|
+
const base = `http://127.0.0.1:${addr.port}`;
|
|
84
|
+
|
|
85
|
+
// ── Scene 1 ───────────────────────────────────────────────────────────────
|
|
86
|
+
scene(1, "A prompt with an email, a phone number, and a deploy secret");
|
|
87
|
+
const userText = "Contact minji.kim@example.com or 010-1234-5678. Deploy api_key=DEMOkey0123456789notARealSecretabcdef.";
|
|
88
|
+
console.log(`${Y}you send →${X} ${userText}`);
|
|
89
|
+
await pause(700);
|
|
90
|
+
const r1 = await fetch(`${base}/v1/chat/completions`, {
|
|
91
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
92
|
+
body: JSON.stringify({ model: "demo", messages: [{ role: "user", content: userText }] })
|
|
93
|
+
});
|
|
94
|
+
const out1 = await r1.json();
|
|
95
|
+
|
|
96
|
+
scene(2, "What the MODEL actually received (the proxy protected it first)");
|
|
97
|
+
const forwarded = JSON.parse(stub.received());
|
|
98
|
+
console.log(`${G}model sees →${X} ${forwarded.messages[0].content}`);
|
|
99
|
+
console.log(`${D} (email → [TOKEN:…], phone → masked, secret → [REDACTED])${X}`);
|
|
100
|
+
await pause(700);
|
|
101
|
+
|
|
102
|
+
scene(3, "What YOU get back — the email token is restored (round-trip)");
|
|
103
|
+
console.log(`${G}you receive →${X} ${out1.choices[0].message.content}`);
|
|
104
|
+
console.log(`${D} (email restored from its token; phone stays masked; keys stay redacted both ways)${X}`);
|
|
105
|
+
await pause(700);
|
|
106
|
+
|
|
107
|
+
// ── Scene 4 ───────────────────────────────────────────────────────────────
|
|
108
|
+
scene(4, "The audit log — tamper-evident, and never any plaintext");
|
|
109
|
+
const audit = (await readFile(auditPath, "utf8")).trim().split("\n");
|
|
110
|
+
const ev = JSON.parse(audit[0]);
|
|
111
|
+
console.log(`${D}detections:${X} ${ev.detections.map((d) => `${d.type}→${d.action}`).join(" ")}`);
|
|
112
|
+
console.log(`${D}leaks the email/secret/phone?${X} ${audit.join("").match(/minji\.kim@|DEMOkey0123|010-1234-5678/) ? R + "YES" + X : G + "no — clean" + X}`);
|
|
113
|
+
await pause(700);
|
|
114
|
+
|
|
115
|
+
// ── Scene 5 ───────────────────────────────────────────────────────────────
|
|
116
|
+
scene(5, "Day-2 operability — live health + Prometheus metrics");
|
|
117
|
+
const ready = await (await fetch(`${base}/__haechi/ready`)).json();
|
|
118
|
+
console.log(`${D}/__haechi/ready →${X} ${ready.ready ? G + "ready" : R + "not ready"}${X} ${D}(audit writable: ${ready.checks?.auditWritable})${X}`);
|
|
119
|
+
const metrics = await (await fetch(`${base}/__haechi/metrics`)).text();
|
|
120
|
+
for (const line of metrics.split("\n").filter((l) => /^haechi_requests_total\{|^haechi_blocks_total /.test(l)).slice(0, 4)) {
|
|
121
|
+
console.log(`${D}metric:${X} ${line}`);
|
|
122
|
+
}
|
|
123
|
+
await pause(700);
|
|
124
|
+
|
|
125
|
+
// ── Scene 6 ───────────────────────────────────────────────────────────────
|
|
126
|
+
scene(6, "A card number is blocked outright (fail-closed)");
|
|
127
|
+
const r2 = await fetch(`${base}/v1/chat/completions`, {
|
|
128
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
129
|
+
body: JSON.stringify({ model: "demo", messages: [{ role: "user", content: "charge card 4242 4242 4242 4242 now" }] })
|
|
130
|
+
});
|
|
131
|
+
console.log(`${Y}you send →${X} "charge card 4242 4242 4242 4242 now"`);
|
|
132
|
+
console.log(`${G}proxy →${X} HTTP ${r2.status} ${r2.status === 403 ? R + B + "BLOCKED" + X : ""} ${D}(the card never reaches the model)${X}`);
|
|
133
|
+
|
|
134
|
+
console.log();
|
|
135
|
+
rule();
|
|
136
|
+
console.log(`${B}${G} ✓ done${X} ${D}— detection → redact/tokenize/block → forward → audit, all local.${X}`);
|
|
137
|
+
rule();
|
|
138
|
+
console.log(`${D} config reference: haechi.config.example.json · docs/current/configuration.md${X}\n`);
|
|
139
|
+
|
|
140
|
+
await proxy.close();
|
|
141
|
+
stub.server.close();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main().then(() => process.exit(0)).catch((e) => { console.error("demo failed:", e); process.exit(1); });
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# VHS tape for the Haechi local end-to-end demo.
|
|
2
|
+
# Regenerate the README GIF with: vhs examples/local-proxy-demo/demo.tape
|
|
3
|
+
# (run from the repo root; requires vhs + ttyd + ffmpeg)
|
|
4
|
+
|
|
5
|
+
Output docs/assets/haechi-demo.gif
|
|
6
|
+
|
|
7
|
+
Set Shell "bash"
|
|
8
|
+
Set FontSize 15
|
|
9
|
+
Set Width 1180
|
|
10
|
+
Set Height 840
|
|
11
|
+
Set Padding 18
|
|
12
|
+
Set Theme "Catppuccin Mocha"
|
|
13
|
+
Set TypingSpeed 55ms
|
|
14
|
+
|
|
15
|
+
Sleep 500ms
|
|
16
|
+
Type "node examples/local-proxy-demo/demo.mjs"
|
|
17
|
+
Sleep 600ms
|
|
18
|
+
Enter
|
|
19
|
+
Sleep 9s
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Live end-to-end demo against a REAL upstream model (vLLM / Ollama / any
|
|
3
|
+
// OpenAI-compatible server). Unlike demo.mjs (which uses a deterministic stub),
|
|
4
|
+
// this proves protection against an actual model: it asks the model to repeat the
|
|
5
|
+
// phone number it was given, and the model can only return the *masked* form —
|
|
6
|
+
// the real number never reached it.
|
|
7
|
+
//
|
|
8
|
+
// HAECHI_LIVE_UPSTREAM=http://127.0.0.1:8000 \
|
|
9
|
+
// HAECHI_LIVE_MODEL="Qwen/Qwen3.6-35B-A3B-FP8" \
|
|
10
|
+
// node examples/local-proxy-demo/live-demo.mjs
|
|
11
|
+
//
|
|
12
|
+
// Defaults: type=vllm-openai. HAECHI_LIVE_TYPE and HAECHI_LIVE_MODEL override.
|
|
13
|
+
// Zero dependencies — only node: builtins + the in-repo haechi packages.
|
|
14
|
+
|
|
15
|
+
import { mkdtemp, readFile } from "node:fs/promises";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { createRuntime } from "../../packages/cli/runtime.mjs";
|
|
20
|
+
import { createHaechiProxy } from "../../packages/proxy/index.mjs";
|
|
21
|
+
import { initLocalKeyFile } from "../../packages/crypto/index.mjs";
|
|
22
|
+
|
|
23
|
+
const B = "\x1b[1m", D = "\x1b[2m", G = "\x1b[32m", Y = "\x1b[33m", C = "\x1b[36m", R = "\x1b[31m", X = "\x1b[0m";
|
|
24
|
+
const rule = () => console.log(D + "─".repeat(64) + X);
|
|
25
|
+
const scene = (n, t) => { console.log(); rule(); console.log(`${B}${C} ${n}. ${t}${X}`); rule(); };
|
|
26
|
+
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
|
|
28
|
+
const UPSTREAM = process.env.HAECHI_LIVE_UPSTREAM;
|
|
29
|
+
const TYPE = process.env.HAECHI_LIVE_TYPE || "vllm-openai";
|
|
30
|
+
const MODEL = process.env.HAECHI_LIVE_MODEL || "Qwen/Qwen3.6-35B-A3B-FP8";
|
|
31
|
+
if (!UPSTREAM) {
|
|
32
|
+
console.error("Set HAECHI_LIVE_UPSTREAM (e.g. http://127.0.0.1:8000) to a reachable OpenAI-compatible server.");
|
|
33
|
+
console.error("For a no-backend reproducible run, use: npm run demo");
|
|
34
|
+
process.exit(2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function chat(base, content, extra = {}) {
|
|
38
|
+
const t0 = Date.now();
|
|
39
|
+
const res = await fetch(`${base}/v1/chat/completions`, {
|
|
40
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
41
|
+
body: JSON.stringify({ model: MODEL, max_tokens: 128, temperature: 0,
|
|
42
|
+
// Qwen3 reasoning models: ask for a direct answer (no chain-of-thought) so
|
|
43
|
+
// the demo gets a terse content reply. Ignored by non-reasoning servers.
|
|
44
|
+
chat_template_kwargs: { enable_thinking: false },
|
|
45
|
+
messages: [{ role: "user", content }], ...extra })
|
|
46
|
+
});
|
|
47
|
+
const body = await res.json();
|
|
48
|
+
return { status: res.status, ms: Date.now() - t0, text: body.choices?.[0]?.message?.content ?? body.error?.message ?? "(no content)" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
console.log(`\n${B}🛡 Haechi — LIVE end-to-end demo${X} ${D}(real model: ${MODEL} via ${TYPE}, enforce mode)${X}`);
|
|
53
|
+
|
|
54
|
+
const dir = await mkdtemp(join(tmpdir(), "haechi-live-"));
|
|
55
|
+
const keyFile = join(dir, ".haechi", "dev.keys.json");
|
|
56
|
+
const auditPath = join(dir, ".haechi", "audit.jsonl");
|
|
57
|
+
await initLocalKeyFile(keyFile, { force: true });
|
|
58
|
+
|
|
59
|
+
const runtime = createRuntime({
|
|
60
|
+
mode: "enforce",
|
|
61
|
+
target: { type: TYPE, upstream: UPSTREAM },
|
|
62
|
+
policy: { mode: "enforce", presets: ["llm-redact"], actions: { email: "tokenize", phone: "mask", secret: "redact", api_key: "redact", card: "block" } },
|
|
63
|
+
tokenVault: { detokenizeResponses: true },
|
|
64
|
+
responseProtection: { enabled: true, mode: "enforce", failureMode: "fail-closed" },
|
|
65
|
+
keys: { keyFile }, audit: { path: auditPath }
|
|
66
|
+
});
|
|
67
|
+
const proxy = createHaechiProxy({ runtime, port: 0 });
|
|
68
|
+
const addr = await proxy.listen();
|
|
69
|
+
const base = `http://127.0.0.1:${addr.port}`;
|
|
70
|
+
|
|
71
|
+
// ── Scene 1 ────────────────────────────────────────────────────────────────
|
|
72
|
+
scene(1, "Ask a REAL model to repeat the phone number you give it");
|
|
73
|
+
const prompt = "Reply in one short line: repeat the phone number you were given. Phone: 010-1234-5678, email minji.kim@example.com";
|
|
74
|
+
console.log(`${Y}you send →${X} ${prompt}`);
|
|
75
|
+
await pause(700);
|
|
76
|
+
const r1 = await chat(base, prompt);
|
|
77
|
+
|
|
78
|
+
scene(2, "Haechi detected + protected the prompt BEFORE it left your machine");
|
|
79
|
+
const events = (await readFile(auditPath, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
|
|
80
|
+
const ev = events.find((e) => Array.isArray(e.detections) && e.detections.length) ?? events[0];
|
|
81
|
+
console.log(`${D}detections:${X} ${(ev.detections ?? []).map((d) => `${G}${d.type}→${d.action}${X}`).join(" ")}`);
|
|
82
|
+
console.log(`${D}the model only ever saw:${X} email → ${C}[TOKEN:…]${X}, phone → ${C}01*********78${X}`);
|
|
83
|
+
await pause(700);
|
|
84
|
+
|
|
85
|
+
scene(3, "The real model replies — it can only return the MASKED phone");
|
|
86
|
+
console.log(`${G}${MODEL.split("/").pop()} →${X} ${B}${r1.text}${X} ${D}(${r1.ms} ms)${X}`);
|
|
87
|
+
console.log(`${D} your real number 010-1234-5678 never reached the model — it cannot reveal it.${X}`);
|
|
88
|
+
await pause(700);
|
|
89
|
+
|
|
90
|
+
// ── Scene 4 ────────────────────────────────────────────────────────────────
|
|
91
|
+
scene(4, "The audit log — hash-chained, and never any plaintext");
|
|
92
|
+
const auditRaw = await readFile(auditPath, "utf8");
|
|
93
|
+
console.log(`${D}leaks the real email/phone?${X} ${/minji\.kim@example|010-1234-5678/.test(auditRaw) ? R + "YES" + X : G + "no — clean" + X}`);
|
|
94
|
+
await pause(700);
|
|
95
|
+
|
|
96
|
+
// ── Scene 5 ────────────────────────────────────────────────────────────────
|
|
97
|
+
scene(5, "Day-2 operability — live readiness + Prometheus metrics");
|
|
98
|
+
const ready = await (await fetch(`${base}/__haechi/ready`)).json();
|
|
99
|
+
console.log(`${D}/__haechi/ready →${X} ${ready.ready ? G + "ready" : R + "not ready"}${X}`);
|
|
100
|
+
const metrics = await (await fetch(`${base}/__haechi/metrics`)).text();
|
|
101
|
+
for (const line of metrics.split("\n").filter((l) => /^haechi_requests_total\{/.test(l)).slice(0, 3)) {
|
|
102
|
+
console.log(`${D}metric:${X} ${line}`);
|
|
103
|
+
}
|
|
104
|
+
await pause(700);
|
|
105
|
+
|
|
106
|
+
// ── Scene 6 ────────────────────────────────────────────────────────────────
|
|
107
|
+
scene(6, "A card number is blocked before it ever reaches the model");
|
|
108
|
+
const r2 = await chat(base, "charge card 4242 4242 4242 4242 now");
|
|
109
|
+
console.log(`${Y}you send →${X} "charge card 4242 4242 4242 4242 now"`);
|
|
110
|
+
console.log(`${G}proxy →${X} HTTP ${r2.status} ${r2.status === 403 ? R + B + "BLOCKED" + X : ""} ${D}(no upstream call made)${X}`);
|
|
111
|
+
|
|
112
|
+
console.log();
|
|
113
|
+
rule();
|
|
114
|
+
console.log(`${B}${G} ✓ live${X} ${D}— a real model, and your PII never left the gateway in the clear.${X}`);
|
|
115
|
+
rule();
|
|
116
|
+
console.log();
|
|
117
|
+
|
|
118
|
+
await proxy.close();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
main().then(() => process.exit(0)).catch((e) => { console.error("live demo failed:", e); process.exit(1); });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# VHS tape for the Haechi LIVE demo (real upstream model).
|
|
2
|
+
# Regenerate the README GIF with:
|
|
3
|
+
# HAECHI_LIVE_UPSTREAM is set below via Env so it stays out of the recording.
|
|
4
|
+
# vhs examples/local-proxy-demo/live-demo.tape (run from the repo root)
|
|
5
|
+
|
|
6
|
+
Output docs/assets/haechi-demo.gif
|
|
7
|
+
|
|
8
|
+
Set Shell "bash"
|
|
9
|
+
Set FontSize 15
|
|
10
|
+
Set Width 1180
|
|
11
|
+
Set Height 840
|
|
12
|
+
Set Padding 18
|
|
13
|
+
Set Theme "Catppuccin Mocha"
|
|
14
|
+
Set TypingSpeed 55ms
|
|
15
|
+
|
|
16
|
+
# Point these at a reachable OpenAI-compatible server before recording. Using Env
|
|
17
|
+
# (not the typed command) keeps the upstream URL out of the captured GIF.
|
|
18
|
+
Env HAECHI_LIVE_UPSTREAM "http://127.0.0.1:8000"
|
|
19
|
+
Env HAECHI_LIVE_MODEL "Qwen/Qwen3.6-35B-A3B-FP8"
|
|
20
|
+
|
|
21
|
+
Sleep 500ms
|
|
22
|
+
Type "node examples/local-proxy-demo/live-demo.mjs"
|
|
23
|
+
Sleep 600ms
|
|
24
|
+
Enter
|
|
25
|
+
Sleep 9s
|