haechi 1.3.3 → 1.4.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 -4
- package/README.md +49 -5
- package/docs/current/config-version.md +2 -2
- package/docs/current/configuration.ko.md +2 -2
- package/docs/current/configuration.md +2 -2
- package/docs/current/operations-runbook.ko.md +1 -1
- package/docs/current/operations-runbook.md +1 -1
- package/docs/current/plugin-signing-and-trust.ko.md +143 -0
- package/docs/current/plugin-signing-and-trust.md +148 -0
- package/docs/current/release-process.ko.md +1 -1
- package/docs/current/release-process.md +1 -1
- package/docs/current/risk-register-release-gate.ko.md +6 -5
- package/docs/current/risk-register-release-gate.md +5 -4
- package/docs/current/shared-responsibility.ko.md +1 -1
- package/docs/current/shared-responsibility.md +1 -1
- package/docs/current/threat-model.ko.md +1 -1
- package/docs/current/threat-model.md +1 -1
- package/package.json +1 -1
- package/packages/cli/bin/haechi.mjs +275 -3
package/README.ko.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](https://github.com/raeseoklee/haechi/actions/workflows/ci.yml)
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
|
-
[](docs/current/api-stability.md)
|
|
12
12
|
|
|
13
13
|
[English](README.md) | **한국어**
|
|
14
14
|
|
|
@@ -16,6 +16,10 @@ Haechi는 LLM·MCP·vLLM·Ollama 및 에이전트 payload가 모델, 도구, 로
|
|
|
16
16
|
|
|
17
17
|
이름은 분별과 보호를 상징하는 한국의 수호 신수 해치에서 따왔습니다.
|
|
18
18
|
|
|
19
|
+
**무엇인가:** 직접 운영하는 로컬 자체 호스팅 **게이트웨이이자 라이브러리**입니다. OpenAI 호환 / MCP / vLLM / Ollama / 에이전트 JSON을 검사하여, PII와 비밀이 모델, 도구, 로그에 도달하기 전에 redact·mask·tokenize·encrypt하거나 차단합니다.
|
|
20
|
+
|
|
21
|
+
**무엇이 *아닌가*:** 즉시 사용 가능한 **운영용 어플라이언스**, 관리형/호스팅 서비스, 컴플라이언스 보장이 아닙니다. 코어에는 운영용 KMS/HSM도, 내장 TLS도, 인터넷 노출 대비 강화도 들어 있지 않습니다 — 네트워크 통제, 인증, 키 custody, TLS 종단 reverse proxy는 **사용자**가 직접 갖춰야 합니다. 배포하기 전에 [Known limitations](#known-limitations)를 참고하십시오.
|
|
22
|
+
|
|
19
23
|
이 저장소는 로컬 개발, 보안 설계 검토, 자체 호스팅 통합 실험을 위한 것입니다. 컴플라이언스를 보장하지는 않습니다.
|
|
20
24
|
|
|
21
25
|
**1.0.0이 첫 stable 릴리스입니다.** 1.0부터 public API는 strict semver 하의 frozen 계약입니다. `package.json`의 `exports` 표면, CLI의 기계 판독 동작, audit event schema, config key shape이 모두 major 버전 계약의 일부이며, 문서화된 deprecation 정책과 in-minor 보안 예외 하나가 함께 적용됩니다. [`docs/current/api-stability.md`](docs/current/api-stability.md)를 참고하세요. `haechi-*` 위성은 pre-1.0으로 유지되며 core와 독립적으로 버저닝됩니다([위성 패키지](#위성-패키지) 참고).
|
|
@@ -29,6 +33,7 @@ Haechi는 LLM·MCP·vLLM·Ollama 및 에이전트 payload가 모델, 도구, 로
|
|
|
29
33
|
- `haechi status`: 현재 설정에서 무엇이 보호되고 무엇이 보호되지 않는지 보여 줍니다
|
|
30
34
|
- `haechi audit-verify`: audit hash chain을 검증하고 head hash를 출력합니다
|
|
31
35
|
- `haechi mcp-wrap -- <command>`: MCP 서버를 양방향 stdio 보호로 감쌉니다
|
|
36
|
+
- `haechi plugin-keygen` / `plugin-sign` / `plugin-verify`: 서명된 `authProvider` 플러그인을 저작하고 검증합니다(Ed25519 신뢰 게이트)
|
|
32
37
|
|
|
33
38
|
## 데모
|
|
34
39
|
|
|
@@ -54,17 +59,32 @@ HAECHI_LIVE_UPSTREAM=http://127.0.0.1:8000 node examples/local-proxy-demo/live-d
|
|
|
54
59
|
|
|
55
60
|
## 설치
|
|
56
61
|
|
|
62
|
+
### npm
|
|
63
|
+
|
|
57
64
|
```bash
|
|
58
|
-
npm install -g haechi
|
|
65
|
+
npm install -g haechi # or: npx haechi init (run without installing)
|
|
59
66
|
haechi init
|
|
60
67
|
```
|
|
61
68
|
|
|
62
|
-
|
|
69
|
+
배포된 패키지의 공급망을 검증하십시오(`0.3.2` 이후 모든 릴리스에 attestation이 붙습니다):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm audit signatures # npm SLSA provenance attestation
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Docker (GHCR)
|
|
76
|
+
|
|
77
|
+
각 릴리스는 **cosign으로 서명된** 이미지를 `ghcr.io/raeseoklee/haechi`(태그 `1.3.3`, `1.3`, `1`, `latest`)에 발행합니다. 검증한 뒤 **TLS 종단 reverse proxy 뒤에서** 실행하십시오(이미지는 `proxy.trustForwardedProto: true`로 `0.0.0.0`에 바인딩합니다):
|
|
63
78
|
|
|
64
79
|
```bash
|
|
65
|
-
|
|
80
|
+
cosign verify ghcr.io/raeseoklee/haechi:1.3.3 \
|
|
81
|
+
--certificate-identity-regexp '^https://github.com/raeseoklee/haechi/' \
|
|
82
|
+
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
|
83
|
+
docker run --rm -p 127.0.0.1:11016:11016 ghcr.io/raeseoklee/haechi:1.3.3
|
|
66
84
|
```
|
|
67
85
|
|
|
86
|
+
강화된 compose 스택과 day-2 운영은 [`docs/current/operations-runbook.md`](docs/current/operations-runbook.md)를 참고하십시오.
|
|
87
|
+
|
|
68
88
|
## 빠른 시작
|
|
69
89
|
|
|
70
90
|
이 저장소를 클론한 뒤:
|
|
@@ -291,6 +311,22 @@ Haechi는 로컬 정책 부트스트래핑을 위한 지역별 기본 Privacy Pr
|
|
|
291
311
|
|
|
292
312
|
`haechi.config.json`에서 `privacy.profile`을 설정하면 집행 전에 해당 프로필의 기본 action이 적용됩니다. 이 프로필은 엔지니어링 기본값이며, 법적 자문이 아닙니다.
|
|
293
313
|
|
|
314
|
+
## Known limitations
|
|
315
|
+
|
|
316
|
+
Haechi는 의도적으로 범위를 좁혔습니다. 아래는 숨기지 않고 솔직하게 밝히는 현재의 실제 한계입니다.
|
|
317
|
+
|
|
318
|
+
- **탐지는 ML이 아니라 정규식 + 검증기입니다.** 규칙은 prefix/charclass/length로 앵커링되고 checksum 검증기(Luhn, KR RRN, IBAN mod-97, national-ID 검사)를 함께 씁니다 — 알려진 형태에는 강한 정밀도를 보이지만, 새롭거나 난독화된 비밀은 놓칠 수 있습니다. `filters.minConfidence` / `filters.allowlist`로 튜닝하십시오. ML/분류기 레이어는 백로그이며 아직 출시되지 않았습니다.
|
|
319
|
+
- **스트리밍 매칭 윈도우에는 한계가 있습니다.** cross-frame PII는 JSON **델타 채널**에서 `streaming.maxMatchBytes`(기본값 256)까지 잡힙니다. **비JSON** SSE/NDJSON 프레임에 걸쳐 나뉜 매치는 프레임 단위로만 검사됩니다(문서화된 잔존 위험).
|
|
320
|
+
- **응답 검사는 보조 방어입니다.** 응답 방향은 기본적으로 bare JSON **number** leaf를 스캔하지 않습니다(inference 서버 메타데이터라 false-positive만 냅니다). 엄격한 위협 모델에서는 `responseProtection.scanNumbers: true`로 활성화하십시오.
|
|
321
|
+
- **MCP `--stderr filter`는 줄 단위입니다.** 완전한 stderr 한 줄씩 보호하므로, 자식이 개행을 넘어 쪼갠 비밀은 잡지 못합니다(앵커링된 정규식은 `\n`을 가로질러 매치할 수 없습니다). 고민감 로컬 도구에는 `--stderr drop`을 쓰십시오.
|
|
322
|
+
- **Audit 꼬리 절단에는 별도 미디어가 필요합니다.** `haechi audit-verify`는 수정, 재정렬, 중간 변조를 탐지합니다. *꼬리* 레코드 삭제는 추가 전용/별도 저장소에 기록된 `audit.anchor`로만 탐지할 수 있습니다.
|
|
323
|
+
- **Rate limiting은 기본적으로 프로세스별입니다.** N개 replica 뒤에서는 내장 limiter가 독립적으로 카운트합니다 — 플릿 전체 예산을 쓰려면 공유 저장소(`haechi-ratelimit-redis` 위성)를 주입하십시오.
|
|
324
|
+
- **플러그인 샌드박스: 기본 `worker_threads` 모드는 capability 샌드박스가 아닙니다**(Ed25519 trust gate로 게이트된 메모리/크래시 격리 + 데이터 최소화입니다). 진정한 커널 강제 봉쇄는 opt-in `process-isolated` 런타임이며, 이는 `--allow-net`을 강제하는 Node를 필요로 합니다.
|
|
325
|
+
- **코어에는 운영용 키 custody가 없습니다.** 로컬 AES-256-GCM 소프트웨어 키 파일은 **개발 전용**입니다. KMS/HSM/Vault 기반 custody에는 `haechi-crypto-kms` 위성을 쓰십시오.
|
|
326
|
+
- **CI 참고:** GHCR 이미지 발행 워크플로의 `docker/*` 액션은 아직 Node 20에서 실행됩니다(GitHub deprecation 경고이며 non-blocking입니다) — pin되어 있고 Node-24 bump이 예정되어 있습니다.
|
|
327
|
+
|
|
328
|
+
**의도적으로 범위 밖 (won't fix):** URL 쿼리 스트링 스캔; 항상 켜진 base64/인코딩 값 디코딩(`filters.decodeAndRescan`을 통한 opt-in만 지원); 대시보드 쓰기 동작(audit 뷰어는 설계상 읽기 전용); OS 수준(seccomp) 플러그인 샌드박싱; 그리고 일체의 컴플라이언스 인증. **Haechi는 컴플라이언스를 보장하지 않습니다.**
|
|
329
|
+
|
|
294
330
|
## 보안 노트
|
|
295
331
|
|
|
296
332
|
- 이 프로젝트는 컴플라이언스를 보장하지 않습니다.
|
|
@@ -335,3 +371,9 @@ Haechi는 로컬 정책 부트스트래핑을 위한 지역별 기본 Privacy Pr
|
|
|
335
371
|
1.0.0은 **첫 stable 릴리스**입니다. strict semver 하의 frozen API 계약을 선언합니다. `package.json`의 `exports` 표면, CLI의 기계 판독 동작, audit event schema(중첩 sub-schema와 `schemaVersion` 포함), config key shape이 모두 major 버전 계약의 일부이며, `tests/api-contract.test.mjs`가 이를 가드하고, 문서화된 deprecation 정책(`HAECHI_DEPRECATION_*` 런타임 경고, 제거는 다음 major에서만)과 공개된 취약점에 대한 in-minor 보안 예외 하나가 이를 규율합니다([`docs/current/api-stability.md`](docs/current/api-stability.md) 참고). 1.0은 또한 dynamic-loading 금지를 **좁게** 해제합니다 — `authProvider` 플러그인에 한해, Ed25519 서명(trust-anchor 전용 키 해석, entry-hash 바인딩, 버전 pin/floor, revocation, 서명 윈도우를 갖춘 비대칭 `node:crypto` 검증)에 capability-gated, `worker_threads` 격리, 완전 감사되는 플러그인 샌드박스를 허용합니다. dependency injection(`createRuntime(config, providers)`)이 기본으로 유지됩니다. **정직한 잔존 위험:** worker는 메모리/크래시 격리와 데이터 최소화일 뿐 capability 샌드박스가 아니므로, 악의적인 *서명된* 플러그인은 여전히 `fs`/`net`을 써서 받은 credential 슬라이스를 유출할 수 있습니다. 따라서 load-bearing 통제는 trust gate이며, 진정한 capability 강제(child-process + Node permission model)는 1.x 목표입니다. 네 개의 `haechi-*` 위성(`haechi-auth-jwt@0.2.1`, `haechi-crypto-kms@0.2.1`, `haechi-dashboard@0.1.2`, `haechi-auth-oidc@0.1.2`)은 pre-1.0으로 유지되고 독립적으로 버저닝하며, `haechi` peer 범위를 `>=0.8.0 <2.0.0`으로 넓혀 core 1.0.0이 그 설치를 깨뜨리지 않게 합니다. `docs/current/release-1.0-implementation-scope.md` 참고.
|
|
336
372
|
|
|
337
373
|
1.1.0은 가장 많이 거론되던 1.0의 정직한 잔존 위험을 **진정한 플러그인 capability 강제**로 닫습니다. 새 opt-in `process-isolated` authProvider 런타임(`auth.plugin.isolation: "process"`)은 서명된 플러그인을 Node 권한 모델(`--permission`, **부여 0**) 하의 자식 프로세스에서 실행합니다 — `data:` URL 로드(파일시스템 권한 없음), `stdio: ['ignore','ignore','ignore','ipc']`, 정화된 env. `--allow-net`을 강제하는 Node에서 커널이 플러그인의 `fs`/`net`/`fetch`/`dns`/`child_process`/`worker`는 물론 `process.binding('tcp_wrap')` 우회까지 거부하므로, 악의적 서명 플러그인은 받은 credential을 유출할 수 없습니다. 네트워크 봉쇄는 커널의 `--allow-net` 거부이며(삭제 가능한 JS 하니스가 아닙니다), 기본값 `netEnforcement: "require-permission"`은 `--allow-net`이 없는 Node에서 **fail closed**(생성 거부)합니다. 커스텀 자격증명 플러그인의 경우, **호스트**가 운영자 선언 키 자료를 SSRF 강화 코어 가드(`haechi/ssrf`)로 가져와 IPC로 주입하므로 플러그인은 URL을 직접 지정하지 않습니다. spawn-storm 서킷 브레이커가 재spawn을 제한합니다. 변경되지 않은 1.0 `worker_threads` 모드가 기본으로 유지되며, `process-isolated`는 additive + opt-in(strict semver 하의 **마이너**)입니다. `docs/current/release-1.1-implementation-scope.md` 참고.
|
|
374
|
+
|
|
375
|
+
1.2.0은 Reliability Hardening Track(WS1–WS6, 1.1을 보존하는 기본값 뒤에서 additive)입니다. 레이블링된 코퍼스 기반 탐지 정밀도/재현율 벤치마크 + 회귀 게이트; non-suppressible hard-block-types 불변식을 갖춘 `filters.minConfidence` / `filters.allowlist`; NFKC 유니코드 회피 폴딩; 주입 가능한 rate-limiter 시임; 운영성(`/__haechi/live`+`/ready`, 주입 가능한 `/metrics`, 구조화 로그 + 요청별 `correlationId`, graceful drain, env 오버레이, 강화된 Dockerfile/compose); 그리고 proxy TLS / remote-bind 강화에 더해 OWASP-LLM / NIST 통제 매핑 백서를 담았습니다. `docs/current/reliability-hardening-track.md` 참고.
|
|
376
|
+
|
|
377
|
+
1.3.0은 백엔드와 탐지를 확장합니다. **Anthropic Messages API**와 **Google Gemini API**용 프로토콜 adapter; 클라우드/SaaS provider-key 탐지와 국제 PII(FR/ES/JP/IT/SG/IN/DE/NL national ID, checksum 검증, hard-block-vs-allowlist-clearable 결정은 측정된 collision rate로 결정); proxy 처리량 벤치마크; 그리고 `haechi-ratelimit-redis` 공유 저장소 rate-limiter 위성을 추가합니다. 모두 additive입니다(새 `target.type`/탐지 type/profile *값*, `configVersion`은 `1`로 유지).
|
|
378
|
+
|
|
379
|
+
1.3.1 → 1.3.3은 보안 교정과 강화 **패치**입니다(API/config 변경 없음). 1.3.1과 1.3.2는 두 차례의 외부 코드 리뷰 라운드를 닫습니다 — proxy 헤더 경계 credential 유출, hex IPv4-mapped IPv6 SSRF, 응답 헤더/스트리밍 경계, 비JSON 스트리밍 검사(1.3.1); proxy upstream-reader 연결 끊김 시 취소, token-vault audit 로그 위생, 플러그인 IPC 응답 경계(1.3.2). 1.3.3은 응답 방향 마커 skip을 강화하고(모델이 비밀을 가짜 `[TOKEN:…]`로 감싸 스캔을 회피할 수 없음) cosign으로 서명된 GHCR 컨테이너 이미지를 추가합니다.
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](https://github.com/raeseoklee/haechi/actions/workflows/ci.yml)
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
|
-
[](docs/current/api-stability.md)
|
|
12
12
|
|
|
13
13
|
**English** | [한국어](README.ko.md)
|
|
14
14
|
|
|
@@ -16,7 +16,11 @@ Haechi is a self-hosted AI context enforcement layer for protecting LLM, MCP, vL
|
|
|
16
16
|
|
|
17
17
|
The name comes from Haechi, a Korean guardian figure associated with discernment and protection.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
**What it is:** a local, self-hosted **gateway and library you run yourself**. It inspects OpenAI-compatible / MCP / vLLM / Ollama / agent JSON and redacts, masks, tokenizes, encrypts, or blocks PII and secrets before they reach models, tools, or logs.
|
|
20
|
+
|
|
21
|
+
**What it is *not*:** a turnkey **production appliance**, a managed/hosted service, or a compliance guarantee. Core ships no production KMS/HSM, no built-in TLS, and no internet-facing hardening — **you** bring the network controls, authentication, key custody, and a TLS-terminating reverse proxy. See [Known limitations](#known-limitations) before deploying.
|
|
22
|
+
|
|
23
|
+
This repository is intended for local development, security design review, and self-hosted integration. It is not a compliance guarantee.
|
|
20
24
|
|
|
21
25
|
**1.0.0 is the first stable release.** From 1.0 the public API is a frozen contract under strict semver: the `package.json` `exports` surface, the CLI's machine-readable behavior, the audit event schema, and the config key shape are all part of the major-versioned contract, with a documented deprecation policy and a one in-minor security exception. See [`docs/current/api-stability.md`](docs/current/api-stability.md). The `haechi-*` satellites stay pre-1.0 and version independently of core (see [Satellite packages](#satellite-packages)).
|
|
22
26
|
|
|
@@ -29,6 +33,7 @@ The current scope focuses on local adoption:
|
|
|
29
33
|
- `haechi status`: show what is and is not protected under the current config
|
|
30
34
|
- `haechi audit-verify`: verify the audit hash chain and print its head hash
|
|
31
35
|
- `haechi mcp-wrap -- <command>`: wrap an MCP server with bidirectional stdio protection
|
|
36
|
+
- `haechi plugin-keygen` / `plugin-sign` / `plugin-verify`: author and verify a signed `authProvider` plugin (Ed25519 trust gate)
|
|
32
37
|
|
|
33
38
|
## Demo
|
|
34
39
|
|
|
@@ -54,17 +59,32 @@ See [`examples/local-proxy-demo/`](examples/local-proxy-demo/).
|
|
|
54
59
|
|
|
55
60
|
## Install
|
|
56
61
|
|
|
62
|
+
### npm
|
|
63
|
+
|
|
57
64
|
```bash
|
|
58
|
-
npm install -g haechi
|
|
65
|
+
npm install -g haechi # or: npx haechi init (run without installing)
|
|
59
66
|
haechi init
|
|
60
67
|
```
|
|
61
68
|
|
|
62
|
-
|
|
69
|
+
Verify the published package's supply chain (every release after `0.3.2` is attested):
|
|
63
70
|
|
|
64
71
|
```bash
|
|
65
|
-
|
|
72
|
+
npm audit signatures # npm SLSA provenance attestation
|
|
66
73
|
```
|
|
67
74
|
|
|
75
|
+
### Docker (GHCR)
|
|
76
|
+
|
|
77
|
+
Each release publishes a **cosign-signed** image to `ghcr.io/raeseoklee/haechi` (tags `1.3.3`, `1.3`, `1`, `latest`). Verify it, then run **behind a TLS-terminating reverse proxy** (the image binds `0.0.0.0` with `proxy.trustForwardedProto: true`):
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
cosign verify ghcr.io/raeseoklee/haechi:1.3.3 \
|
|
81
|
+
--certificate-identity-regexp '^https://github.com/raeseoklee/haechi/' \
|
|
82
|
+
--certificate-oidc-issuer https://token.actions.githubusercontent.com
|
|
83
|
+
docker run --rm -p 127.0.0.1:11016:11016 ghcr.io/raeseoklee/haechi:1.3.3
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
See [`docs/current/operations-runbook.md`](docs/current/operations-runbook.md) for the hardened compose stack and day-2 operations.
|
|
87
|
+
|
|
68
88
|
## Quickstart
|
|
69
89
|
|
|
70
90
|
From a clone of this repository:
|
|
@@ -291,6 +311,22 @@ Haechi includes baseline regional privacy profiles for local policy bootstrappin
|
|
|
291
311
|
|
|
292
312
|
Set `privacy.profile` in `haechi.config.json` to apply the profile's default actions before enforcement. These profiles are engineering defaults, not legal advice.
|
|
293
313
|
|
|
314
|
+
## Known limitations
|
|
315
|
+
|
|
316
|
+
Haechi is deliberately scoped. These are real, current limitations — listed openly, not hidden:
|
|
317
|
+
|
|
318
|
+
- **Detection is regex + validators, not ML.** Rules are anchored on prefix/charclass/length with checksum validators (Luhn, KR RRN, IBAN mod-97, national-ID checks) — strong precision on known shapes, but a novel or obfuscated secret can be missed. Tune with `filters.minConfidence` / `filters.allowlist`; an ML/classifier layer is backlog, not shipped.
|
|
319
|
+
- **Streaming match window is bounded.** Cross-frame PII is caught on the JSON **delta channel** up to `streaming.maxMatchBytes` (default 256). A match split across **non-JSON** SSE/NDJSON frames is inspected per-frame only (documented residual).
|
|
320
|
+
- **Response inspection is a secondary defense.** The response direction does not scan bare JSON **number** leaves by default (they are inference-server metadata and only false-positive); opt in with `responseProtection.scanNumbers: true` for a strict threat model.
|
|
321
|
+
- **MCP `--stderr filter` is line-oriented.** It protects each complete stderr line; a secret a child splits across a newline is not caught (an anchored regex cannot match across `\n`). Use `--stderr drop` for high-sensitivity local tools.
|
|
322
|
+
- **Audit tail-truncation needs separate media.** `haechi audit-verify` detects modification, reordering, and middle tampering; deletion of *trailing* records is only detectable via `audit.anchor` written to append-only / separate storage.
|
|
323
|
+
- **Rate limiting is per-process by default.** Behind N replicas the built-in limiter counts independently — inject a shared store (the `haechi-ratelimit-redis` satellite) for a fleet-wide budget.
|
|
324
|
+
- **Plugin sandbox: the default `worker_threads` mode is not a capability sandbox** (it is memory/crash isolation + data-minimization, gated by the Ed25519 trust gate). Real kernel-enforced containment is the opt-in `process-isolated` runtime, which requires a Node that enforces `--allow-net`.
|
|
325
|
+
- **No production key custody in core.** The local AES-256-GCM software-key file is **dev-only**; use the `haechi-crypto-kms` satellite for KMS/HSM/Vault-backed custody.
|
|
326
|
+
- **CI note:** the GHCR image-publish workflow's `docker/*` actions still run on Node 20 (a GitHub deprecation warning, non-blocking) — pinned and scheduled for a Node-24 bump.
|
|
327
|
+
|
|
328
|
+
**Deliberately out of scope (won't fix):** URL query-string scanning; always-on base64/encoded-value decoding (opt-in only via `filters.decodeAndRescan`); dashboard write actions (the audit viewer is read-only by design); OS-level (seccomp) plugin sandboxing; and any compliance certification. **Haechi is not a compliance guarantee.**
|
|
329
|
+
|
|
294
330
|
## Security Notes
|
|
295
331
|
|
|
296
332
|
- This project is not a compliance guarantee.
|
|
@@ -335,3 +371,11 @@ Set `privacy.profile` in `haechi.config.json` to apply the profile's default act
|
|
|
335
371
|
1.0.0 is the **first stable release**. It declares a frozen API contract under strict semver: the `package.json` `exports` surface, the CLI's machine-readable behavior, the audit event schema (including its nested sub-schemas and `schemaVersion`), and the config key shape are all part of the major-versioned contract, guarded by `tests/api-contract.test.mjs` and governed by a documented deprecation policy (`HAECHI_DEPRECATION_*` runtime warnings, removal only at the next major) with a single in-minor security exception for disclosed vulnerabilities (see [`docs/current/api-stability.md`](docs/current/api-stability.md)). 1.0 also lifts the dynamic-loading ban **narrowly**, for `authProvider` plugins only: an Ed25519-signed (asymmetric `node:crypto` verification with trust-anchor-only key resolution, entry-hash binding, version pin/floor, revocation, and a signing window), capability-gated, `worker_threads`-isolated, fully audited plugin sandbox. Dependency injection (`createRuntime(config, providers)`) stays the default. **Honest residual:** the worker is memory/crash isolation and data-minimization, not a capability sandbox — a malicious *signed* plugin can still use `fs`/`net` and exfiltrate the credential slice it receives, so the load-bearing control is the trust gate; true capability enforcement (child-process + Node permission model) is a 1.x target. The four `haechi-*` satellites (`haechi-auth-jwt@0.2.1`, `haechi-crypto-kms@0.2.1`, `haechi-dashboard@0.1.2`, `haechi-auth-oidc@0.1.2`) stay pre-1.0, version independently, and widen their `haechi` peer range to `>=0.8.0 <2.0.0` so core 1.0.0 does not break their installs. See `docs/current/release-1.0-implementation-scope.md`.
|
|
336
372
|
|
|
337
373
|
1.1.0 closes the most-cited 1.0 honest residual with **real plugin capability enforcement**: a new opt-in `process-isolated` authProvider runtime (`auth.plugin.isolation: "process"`) runs the signed plugin in a child process under the Node permission model (`--permission`, **zero grants**), loaded from a `data:` URL (no filesystem grant), with `stdio: ['ignore','ignore','ignore','ipc']` and a scrubbed env. On a Node that enforces `--allow-net`, the kernel denies the plugin's `fs`/`net`/`fetch`/`dns`/`child_process`/`worker` *and* the `process.binding('tcp_wrap')` bypass, so a malicious signed plugin cannot exfiltrate the credential it receives. Network containment is the kernel `--allow-net` denial (not a deletable JS harness); the default `netEnforcement: "require-permission"` **fails closed** (refuses to construct) on a Node without `--allow-net`. For a custom-credential plugin, the **host** fetches operator-declared key material through an SSRF-hardened core guard (`haechi/ssrf`) and injects it over the IPC — the plugin never names a URL. A spawn-storm circuit breaker bounds respawns. The unchanged 1.0 `worker_threads` mode stays the default; `process-isolated` is additive and opt-in (a **minor** under strict semver). See `docs/current/release-1.1-implementation-scope.md`.
|
|
374
|
+
|
|
375
|
+
1.2.0 is the Reliability Hardening Track (WS1–WS6, additive behind 1.1-preserving defaults): a labeled-corpus detection precision/recall benchmark + regression gate; `filters.minConfidence` / `filters.allowlist` with a non-suppressible hard-block-types invariant; NFKC unicode-evasion folding; an injectable rate-limiter seam; operability (`/__haechi/live`+`/ready`, injectable `/metrics`, structured logs + per-request `correlationId`, graceful drain, env overlay, hardened Dockerfile/compose); and proxy TLS / remote-bind hardening plus an OWASP-LLM / NIST control-mapping whitepaper. See `docs/current/reliability-hardening-track.md`.
|
|
376
|
+
|
|
377
|
+
1.3.0 expands backends and detection: protocol adapters for the **Anthropic Messages API** and **Google Gemini API**; cloud/SaaS provider-key detection and international PII (FR/ES/JP/IT/SG/IN/DE/NL national IDs, checksum-validated, each hard-block-vs-allowlist-clearable decision driven by measured collision rates); a proxy throughput benchmark; and the `haechi-ratelimit-redis` shared-store rate-limiter satellite. All additive (new `target.type`/detection-type/profile *values*, `configVersion` stays `1`).
|
|
378
|
+
|
|
379
|
+
1.3.1 → 1.3.3 are security-remediation and hardening **patches** (no API/config change). 1.3.1 and 1.3.2 close two external code-review rounds — proxy header-boundary credential leak, hex IPv4-mapped IPv6 SSRF, response-header/streaming bounds, and non-JSON streaming inspection (1.3.1); proxy upstream-reader cancel-on-disconnect, token-vault audit-log hygiene, and plugin IPC reply bounds (1.3.2). 1.3.3 tightens the response-direction marker skip (a model can't wrap a secret in a fake `[TOKEN:…]` to evade scanning) and adds the cosign-signed GHCR container image.
|
|
380
|
+
|
|
381
|
+
1.4.0 adds the first-party authoring CLI for the signed-plugin trust gate: `haechi plugin-keygen` (Ed25519 keypair — private key `0600`, public key is the trust anchor), `plugin-sign` (signs the exact entry-file bytes; `authProvider` must declare `readsCredentials`), and `plugin-verify` (runs the runtime's verification, fail-closed, with `--allow-capability`). A new [`plugin-signing-and-trust.md`](docs/current/plugin-signing-and-trust.md) runbook covers the keygen → sign → wire `auth.plugin.trustAnchors` → verify → rotate/pin/revoke lifecycle. Additive CLI surface (a **minor** under strict semver); no config change (`configVersion` stays `1`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi `configVersion` & Upgrade Notes
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.4.x)
|
|
4
4
|
|
|
5
5
|
`configVersion` is a single integer stamped at the top of `haechi.config.json`
|
|
6
6
|
(and `haechi.config.example.json`). It is a **versioned anchor** so a future
|
|
@@ -34,7 +34,7 @@ the "policies only get stronger / fail closed" invariant intact.
|
|
|
34
34
|
|
|
35
35
|
| `configVersion` | Core line | Notes |
|
|
36
36
|
|---|---|---|
|
|
37
|
-
| `1` | 1.0 – 1.
|
|
37
|
+
| `1` | 1.0 – 1.4.x | Initial stamp. All keys are additive over the 1.0 frozen config surface (`api-stability.md` §2.4). The 1.1.x additive keys (`logging`, `metrics`, the WS4-B `limits.maxInFlight` / `limits.shutdownGraceMs` / `limits.requestTimeoutMs` / `limits.headersTimeoutMs`, `configVersion` itself) and the 1.2.0 Reliability-Hardening keys (`filters.minConfidence` / `filters.allowlist`, `proxy.tls` / `proxy.trustForwardedProto`) all default to prior behavior. The 1.3.0 additions are new *values*, not new keys — `target.type` `anthropic`/`gemini`, additional detection types, and the `asia-pdpa`/`jp-appi` `privacy.profile` values. The 1.4.0 plugin-signing CLI (`plugin-keygen`/`plugin-sign`/`plugin-verify`) is **CLI surface, not config** — it adds no config keys. So the config schema (and `configVersion`) is unchanged. No migration needed. |
|
|
38
38
|
|
|
39
39
|
## Upgrading
|
|
40
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi 설정 레퍼런스
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document(core 1.
|
|
3
|
+
- 문서 상태: Living document(core 1.4.x 추적)
|
|
4
4
|
|
|
5
5
|
`haechi init`은 `haechi.config.json`을 생성하며, 비밀 정보를 포함하지 않는 템플릿은 `haechi.config.example.json`에 있습니다. 모든 커맨드는 `--config <path>`로 설정 파일을 읽습니다(기본값: `haechi.config.json`). 설정은 **fail-closed 방식으로 검증**됩니다. 알 수 없는 provider, 범위를 벗어난 숫자, 잘못된 형식의 값은 자동으로 무시되지 않고 로드 시점에 오류를 발생시킵니다. `haechi config`는 이 레퍼런스를 출력하며, `haechi status`는 특정 설정 파일의 *실제 적용* 상태를 출력합니다.
|
|
6
6
|
|
|
@@ -235,7 +235,7 @@ const runtime = createRuntime(config, { metrics });
|
|
|
235
235
|
|
|
236
236
|
### `auth.plugin` (signed authProvider sandbox)
|
|
237
237
|
|
|
238
|
-
`auth.provider: "plugin"`일 때 필요합니다. 샌드박스는 **서명된** `authProvider` 플러그인을 capability-gated, 감사되는 런타임에서 로드합니다. 최상위 `plugins.enabled`(기본 `true`)는 kill-switch입니다 — `false`이면 어떤 플러그인 생성도 거부합니다. 동적 로딩은 opt-in이며 기본은 dependency injection입니다. `docs/current/release-1.0-implementation-scope.md`(worker) 및 `release-1.1-implementation-scope.md`(process)를 참고하세요.
|
|
238
|
+
`auth.provider: "plugin"`일 때 필요합니다. 샌드박스는 **서명된** `authProvider` 플러그인을 capability-gated, 감사되는 런타임에서 로드합니다. 최상위 `plugins.enabled`(기본 `true`)는 kill-switch입니다 — `false`이면 어떤 플러그인 생성도 거부합니다. 동적 로딩은 opt-in이며 기본은 dependency injection입니다. `docs/current/release-1.0-implementation-scope.md`(worker) 및 `release-1.1-implementation-scope.md`(process)를 참고하세요. 종단 간 서명 + 신뢰 앵커 큐레이션 흐름(`haechi plugin-keygen`/`plugin-sign`/`plugin-verify` CLI)은 [`plugin-signing-and-trust.md`](plugin-signing-and-trust.md)를 참고하세요.
|
|
239
239
|
|
|
240
240
|
| Key | Type / values | Default | Notes |
|
|
241
241
|
|---|---|---|---|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Configuration Reference
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.4.x)
|
|
4
4
|
|
|
5
5
|
`haechi init` writes `haechi.config.json`; a non-secret template is at `haechi.config.example.json`. Every command reads it with `--config <path>` (default `haechi.config.json`). Configuration is **validated fail-closed**: unknown providers, out-of-range numbers, and malformed values throw at load time rather than degrading silently. `haechi config` prints this reference; `haechi status` prints the *effective* state of a given config.
|
|
6
6
|
|
|
@@ -235,7 +235,7 @@ Applies to `mcp-stdio` and `mcp-wrap`.
|
|
|
235
235
|
|
|
236
236
|
### `auth.plugin` (signed authProvider sandbox)
|
|
237
237
|
|
|
238
|
-
Required when `auth.provider: "plugin"`. The sandbox loads a **signed** `authProvider` plugin under a capability-gated, audited runtime. The top-level `plugins.enabled` (default `true`) is a kill-switch — `false` refuses to construct any plugin. Dynamic loading is opt-in; the default is dependency injection. See `docs/current/release-1.0-implementation-scope.md` (worker) and `release-1.1-implementation-scope.md` (process).
|
|
238
|
+
Required when `auth.provider: "plugin"`. The sandbox loads a **signed** `authProvider` plugin under a capability-gated, audited runtime. The top-level `plugins.enabled` (default `true`) is a kill-switch — `false` refuses to construct any plugin. Dynamic loading is opt-in; the default is dependency injection. See `docs/current/release-1.0-implementation-scope.md` (worker) and `release-1.1-implementation-scope.md` (process). For the end-to-end signing + trust-anchor curation flow (the `haechi plugin-keygen`/`plugin-sign`/`plugin-verify` CLI), see [`plugin-signing-and-trust.md`](plugin-signing-and-trust.md).
|
|
239
239
|
|
|
240
240
|
| Key | Type / values | Default | Notes |
|
|
241
241
|
|---|---|---|---|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Operations Runbook (Day-2)
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.4.x)
|
|
4
4
|
|
|
5
5
|
A practical guide to running Haechi in production: deploy, configure via the
|
|
6
6
|
env-var overlay, monitor with health/readiness/metrics, shut down gracefully,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# 플러그인 서명 & 신뢰 앵커 큐레이션
|
|
2
|
+
|
|
3
|
+
- 상태: Living document (코어 1.4.x 추적)
|
|
4
|
+
- 날짜: 2026-06-17
|
|
5
|
+
|
|
6
|
+
Haechi의 기본은 **dependency injection**입니다 — `createRuntime(config, providers)`에
|
|
7
|
+
`authProvider`를 전달하며, 어떤 코드도 동적으로 로드되지 않습니다. 서명된 플러그인
|
|
8
|
+
샌드박스(`auth.provider: "plugin"`)는 그 **opt-in** 예외입니다: 운영자가 자신이 통제하는
|
|
9
|
+
키로 서명하고 그 키를 **신뢰 앵커(trust anchor)**로 allowlist한 경우에만 서드파티
|
|
10
|
+
`authProvider`를 로드합니다. 이 핵심 통제는 그 신뢰 게이트(Ed25519 서명 + 운영자
|
|
11
|
+
allowlist + pin/floor/revocation)이지 샌드박스 격리가 아닙니다 —
|
|
12
|
+
[`api-stability.md`](api-stability.md)와 [위협 모델](threat-model.md)을 참고하십시오.
|
|
13
|
+
|
|
14
|
+
이 런북은 `haechi plugin-*` CLI를 사용한 종단 간 저작 + 큐레이션 흐름입니다. 전체
|
|
15
|
+
`auth.plugin.*` 키 레퍼런스는
|
|
16
|
+
[`configuration.md`](configuration.md#authplugin-signed-authprovider-sandbox)를
|
|
17
|
+
참고하십시오.
|
|
18
|
+
|
|
19
|
+
## 1. 서명 키쌍 생성
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
haechi plugin-keygen --key-id acme-signer --out-dir ./keys
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `./keys/acme-signer.key`를 기록합니다 — **개인** 서명 키(PKCS8 PEM, 모드 `0600`).
|
|
26
|
+
오프라인 / 본인의 비밀 저장소에 보관하십시오; Haechi는 런타임에 이를 절대 읽지 않으며
|
|
27
|
+
게이트웨이 호스트에 둘 필요도 없습니다.
|
|
28
|
+
- `./keys/acme-signer.pub`를 기록합니다 — **공개** 키(SPKI PEM). 운영자에게 전달하는
|
|
29
|
+
**신뢰 앵커**이며, 커밋/배포해도 안전합니다.
|
|
30
|
+
- JSON 출력은 경로와 공개 PEM만 담습니다 — 개인 키 자료는 **절대** 담지 않습니다.
|
|
31
|
+
|
|
32
|
+
안정적이고 의미 있는 `keyId`를 사용하십시오(설정과 audit 로그에서 앵커를 라벨링합니다).
|
|
33
|
+
하나의 서명 키로 여러 플러그인에 서명할 수 있습니다.
|
|
34
|
+
|
|
35
|
+
## 2. 플러그인 서명
|
|
36
|
+
|
|
37
|
+
**정확한** entry 파일 바이트에 서명하십시오 — 서명은 `sha256(entry bytes)`에 바인딩되므로,
|
|
38
|
+
이후 플러그인 소스에 어떤 편집을 가하든 무효화됩니다.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
haechi plugin-sign ./acme-auth.mjs \
|
|
42
|
+
--key ./keys/acme-signer.key \
|
|
43
|
+
--signer-key-id acme-signer \
|
|
44
|
+
--plugin-id acme-auth \
|
|
45
|
+
--kind authProvider \
|
|
46
|
+
--plugin-version 1.0.0 \
|
|
47
|
+
--core-range ">=1.0.0 <2.0.0" \
|
|
48
|
+
--capabilities '{"readsCredentials":true}' \
|
|
49
|
+
--out acme-auth.signed.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `authProvider` 플러그인은 **반드시** `readsCredentials: true`를 선언해야 합니다(선언하지
|
|
53
|
+
않으면 코어가 거부합니다). `--capabilities`는 JSON 파일을 읽는 `@path` 형식도 받습니다.
|
|
54
|
+
- 개인 키는 `--key` **파일**에서 읽으며, 커맨드 라인에서는 절대 읽지 않습니다(argv의 키는
|
|
55
|
+
셸 히스토리와 프로세스 테이블로 누출됩니다).
|
|
56
|
+
- 선택적 `--not-before` / `--not-after`(epoch ms)는 서명 윈도우를 한정합니다.
|
|
57
|
+
- 서명된 envelope `{ payload, signerKeyId, alg, signature }`를 `--out`(기본
|
|
58
|
+
`<pluginId>.signed.json`)에 기록합니다.
|
|
59
|
+
|
|
60
|
+
## 3. 신뢰하기 전에 검증
|
|
61
|
+
|
|
62
|
+
`plugin-verify`는 런타임이 로드 시점에 수행하는 검증과 **동일한** 검증을 실행하므로,
|
|
63
|
+
envelope를 연결하기 전에 그것이 정상인지 확인할 수 있습니다. 이는 **fail closed**입니다: 어떤
|
|
64
|
+
거부든 안정적인 `PluginLoadError` 사유(게이트 신호)와 함께 non-zero로 종료합니다; 잘못된
|
|
65
|
+
envelope에 대해 `valid:true`를 절대 출력하지 않습니다.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
haechi plugin-verify acme-auth.signed.json \
|
|
69
|
+
--entry ./acme-auth.mjs \
|
|
70
|
+
--anchor ./keys/acme-signer.pub \
|
|
71
|
+
--allow-capability readsCredentials \
|
|
72
|
+
--core-version 1.3.3
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- `--allow-capability <name>`(반복 가능)는 verifier의 capability allowlist입니다.
|
|
76
|
+
`authProvider`를 검증하려면 **필수**입니다(그 필수 `readsCredentials`는 기본적으로
|
|
77
|
+
allowlist되지 않습니다) — 없으면 fail-closed `capability-not-allowlisted`를 받습니다.
|
|
78
|
+
- 앵커는 명시적 `--anchor <pub.pem>`(`--anchor-key-id`와 함께, 기본은 envelope의
|
|
79
|
+
`signerKeyId`)에서 해석하거나, 실행 중인 설정에서
|
|
80
|
+
`--config haechi.config.json`(`auth.plugin.trustAnchors`를 읽음)으로 해석합니다.
|
|
81
|
+
- `--pin <entrySha256>`와 `--core-version <v>`는 pin / range 검사를 실행합니다.
|
|
82
|
+
|
|
83
|
+
흔한 거부 사유: `tampered-entry`(서명 후 entry 편집), `invalid-signature`(잘못된 키 /
|
|
84
|
+
변형된 서명), `unknown-signer`(앵커가 allowlist되지 않음), `alg-not-ed25519`,
|
|
85
|
+
`expired-window`, `below-version-floor`, `revoked`, `pin-mismatch`,
|
|
86
|
+
`capability-not-allowlisted`.
|
|
87
|
+
|
|
88
|
+
## 4. 신뢰 앵커를 설정에 연결
|
|
89
|
+
|
|
90
|
+
**공개** 키를 신뢰 앵커로 붙여 넣고 플러그인이 필요로 하는 capability만(그 이상은 안 됨)
|
|
91
|
+
정확히 allowlist하십시오:
|
|
92
|
+
|
|
93
|
+
```jsonc
|
|
94
|
+
{
|
|
95
|
+
"auth": {
|
|
96
|
+
"provider": "plugin",
|
|
97
|
+
"plugin": {
|
|
98
|
+
"manifestPath": "acme-auth.signed.json",
|
|
99
|
+
"trustAnchors": [
|
|
100
|
+
{ "keyId": "acme-signer", "publicKey": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n" }
|
|
101
|
+
],
|
|
102
|
+
"allowCapabilities": ["readsCredentials"],
|
|
103
|
+
"isolation": "process"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- `trustAnchors`는 위의 `{keyId, publicKey}` 배열 형식 또는 `{ keyId: publicKey }` 맵을
|
|
110
|
+
받습니다. 키 해석은 **trust-anchor 전용**입니다 — 여기에 나열되지 않은 서명 키는
|
|
111
|
+
`unknown-signer`이며 fail-closed입니다.
|
|
112
|
+
- 가능한 곳에서는 기본 `worker`보다 `isolation: "process"`(커널 강제 capability 거부;
|
|
113
|
+
`--allow-net`을 강제하는 Node 필요)를 선호하십시오.
|
|
114
|
+
- `plugins.enabled: false`는 어떤 플러그인 생성도 거부하는 전역 kill-switch입니다.
|
|
115
|
+
|
|
116
|
+
## 5. Rotate, pin, revoke (큐레이션 라이프사이클)
|
|
117
|
+
|
|
118
|
+
신뢰 앵커는 운영자가 소유합니다 — 의도적으로 큐레이션하십시오:
|
|
119
|
+
|
|
120
|
+
- **서명 키 rotate.** 새 키를 `plugin-keygen`하고, 그 키로 플러그인을 다시 서명하고, 기존
|
|
121
|
+
앵커와 함께 새 앵커를 `trustAnchors`에 **추가**하십시오. 배포된 모든 플러그인을 다시 서명한
|
|
122
|
+
뒤에 기존 앵커를 제거하십시오. 살아 있는 envelope가 여전히 의존하는 앵커를 조용히
|
|
123
|
+
떨어뜨리지 마십시오(그것은 fail-closed 장애입니다).
|
|
124
|
+
- **정확한 빌드를 pin.** `auth.plugin.pin: { version?, entrySha256?, manifestSha256? }`은
|
|
125
|
+
pin된 빌드 외에는 모두 거부합니다 — 악성 업데이트나 rollback에 대한 방어입니다.
|
|
126
|
+
`plugin-sign`이 출력한 `entrySha256`을 사용하십시오.
|
|
127
|
+
- **버전 floor 설정.** `auth.plugin.versionFloor: { "<pluginId>": "<min>" }`은 floor
|
|
128
|
+
미만의 어떤 버전도 거부합니다(anti-rollback). 정확한 빌드를 pin하지 않고도 동작합니다.
|
|
129
|
+
- **Revoke.** `auth.plugin.revoked: { signerKeyIds?: [...], entrySha256?: [...] }`은
|
|
130
|
+
손상된 서명 키나 특정 불량 빌드를 denylist합니다; revocation은 로드 시점에 fail-closed입니다.
|
|
131
|
+
Revocation은 **다음 로드**(재시작, 또는 살아 있는 플러그인을 강제로 떨어뜨리는 kill-switch)에
|
|
132
|
+
적용됩니다 — 실시간 revocation 피드는 향후 작업입니다(P1-SEC-025 residual).
|
|
133
|
+
|
|
134
|
+
## 6. 운영자 체크리스트
|
|
135
|
+
|
|
136
|
+
- [ ] 개인 서명 키는 오프라인 / 비밀 저장소에 보관하고, 게이트웨이 호스트에는 절대 두지 않음.
|
|
137
|
+
- [ ] `trustAnchors`에는 공개 키만 있음; `allowCapabilities`는 최소 집합임.
|
|
138
|
+
- [ ] 배포 전 `plugin-verify`(일치하는 `--core-version`)로 envelope를 검증함.
|
|
139
|
+
- [ ] rotation 계획(add-new-then-remove-old)이 존재하고 프로덕션에는 `pin`/`versionFloor`가 설정됨.
|
|
140
|
+
- [ ] 가능한 곳에서는 `--allow-net`을 강제하는 Node에서 `isolation: "process"`.
|
|
141
|
+
|
|
142
|
+
참고: [`configuration.md`](configuration.md#authplugin-signed-authprovider-sandbox),
|
|
143
|
+
[`threat-model.md`](threat-model.md), [`api-stability.md`](api-stability.md).
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Plugin Signing & Trust-Anchor Curation
|
|
2
|
+
|
|
3
|
+
- Status: Living document (tracks core 1.4.x)
|
|
4
|
+
- Date: 2026-06-17
|
|
5
|
+
|
|
6
|
+
Haechi's default is **dependency injection** — you pass an `authProvider` to
|
|
7
|
+
`createRuntime(config, providers)` and no code is loaded dynamically. The signed-
|
|
8
|
+
plugin sandbox (`auth.provider: "plugin"`) is the **opt-in** exception: it loads a
|
|
9
|
+
third-party `authProvider` only when the operator has signed it with a key they
|
|
10
|
+
control and allowlisted that key as a **trust anchor**. The load-bearing control
|
|
11
|
+
is that trust gate (Ed25519 signature + operator allowlist + pin/floor/revocation),
|
|
12
|
+
not the sandbox isolation — see [`api-stability.md`](api-stability.md) and the
|
|
13
|
+
[threat model](threat-model.md).
|
|
14
|
+
|
|
15
|
+
This runbook is the end-to-end authoring + curation flow using the `haechi
|
|
16
|
+
plugin-*` CLI. For the full `auth.plugin.*` key reference see
|
|
17
|
+
[`configuration.md`](configuration.md#authplugin-signed-authprovider-sandbox).
|
|
18
|
+
|
|
19
|
+
## 1. Generate a signing keypair
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
haechi plugin-keygen --key-id acme-signer --out-dir ./keys
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- Writes `./keys/acme-signer.key` — the **private** signing key (PKCS8 PEM, mode
|
|
26
|
+
`0600`). Keep it offline / in your own secret store; Haechi never reads it at
|
|
27
|
+
runtime and never needs it on the gateway host.
|
|
28
|
+
- Writes `./keys/acme-signer.pub` — the **public** key (SPKI PEM). This is the
|
|
29
|
+
**trust anchor** you give operators; it is safe to commit/distribute.
|
|
30
|
+
- The JSON output carries only the paths and the public PEM — **never** the
|
|
31
|
+
private key material.
|
|
32
|
+
|
|
33
|
+
Use a stable, meaningful `keyId` (it labels the anchor in config and the audit
|
|
34
|
+
log). One signer key can sign many plugins.
|
|
35
|
+
|
|
36
|
+
## 2. Sign a plugin
|
|
37
|
+
|
|
38
|
+
Sign the **exact** entry-file bytes — the signature binds `sha256(entry bytes)`,
|
|
39
|
+
so any later edit to the plugin source invalidates it.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
haechi plugin-sign ./acme-auth.mjs \
|
|
43
|
+
--key ./keys/acme-signer.key \
|
|
44
|
+
--signer-key-id acme-signer \
|
|
45
|
+
--plugin-id acme-auth \
|
|
46
|
+
--kind authProvider \
|
|
47
|
+
--plugin-version 1.0.0 \
|
|
48
|
+
--core-range ">=1.0.0 <2.0.0" \
|
|
49
|
+
--capabilities '{"readsCredentials":true}' \
|
|
50
|
+
--out acme-auth.signed.json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- An `authProvider` plugin **must** declare `readsCredentials: true` (core rejects
|
|
54
|
+
one that does not). `--capabilities` also accepts `@path` to read a JSON file.
|
|
55
|
+
- The private key is read from the `--key` **file**, never the command line (a key
|
|
56
|
+
in argv leaks into shell history and the process table).
|
|
57
|
+
- Optional `--not-before` / `--not-after` (epoch ms) bound a signing window.
|
|
58
|
+
- Writes the signed envelope `{ payload, signerKeyId, alg, signature }` to
|
|
59
|
+
`--out` (default `<pluginId>.signed.json`).
|
|
60
|
+
|
|
61
|
+
## 3. Verify before you trust it
|
|
62
|
+
|
|
63
|
+
`plugin-verify` runs the **same** verification the runtime does at load, so you
|
|
64
|
+
can confirm an envelope is good before wiring it in. It **fails closed**: any
|
|
65
|
+
refusal exits non-zero with the stable `PluginLoadError` reason (the gate signal);
|
|
66
|
+
it never prints `valid:true` on a bad envelope.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
haechi plugin-verify acme-auth.signed.json \
|
|
70
|
+
--entry ./acme-auth.mjs \
|
|
71
|
+
--anchor ./keys/acme-signer.pub \
|
|
72
|
+
--allow-capability readsCredentials \
|
|
73
|
+
--core-version 1.3.3
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- `--allow-capability <name>` (repeatable) is the verifier's capability allowlist.
|
|
77
|
+
It is **required** to verify an `authProvider` (its mandatory `readsCredentials`
|
|
78
|
+
is not allowlisted by default) — without it you get a fail-closed
|
|
79
|
+
`capability-not-allowlisted`.
|
|
80
|
+
- Resolve anchors from an explicit `--anchor <pub.pem>` (with `--anchor-key-id`,
|
|
81
|
+
default the envelope's `signerKeyId`) **or** from a running config with
|
|
82
|
+
`--config haechi.config.json` (reads `auth.plugin.trustAnchors`).
|
|
83
|
+
- `--pin <entrySha256>` and `--core-version <v>` exercise the pin / range checks.
|
|
84
|
+
|
|
85
|
+
Common refusal reasons: `tampered-entry` (entry edited after signing),
|
|
86
|
+
`invalid-signature` (wrong key / mutated signature), `unknown-signer` (anchor not
|
|
87
|
+
allowlisted), `alg-not-ed25519`, `expired-window`, `below-version-floor`,
|
|
88
|
+
`revoked`, `pin-mismatch`, `capability-not-allowlisted`.
|
|
89
|
+
|
|
90
|
+
## 4. Wire the trust anchor into config
|
|
91
|
+
|
|
92
|
+
Paste the **public** key as a trust anchor and allowlist exactly the capabilities
|
|
93
|
+
the plugin needs (no more):
|
|
94
|
+
|
|
95
|
+
```jsonc
|
|
96
|
+
{
|
|
97
|
+
"auth": {
|
|
98
|
+
"provider": "plugin",
|
|
99
|
+
"plugin": {
|
|
100
|
+
"manifestPath": "acme-auth.signed.json",
|
|
101
|
+
"trustAnchors": [
|
|
102
|
+
{ "keyId": "acme-signer", "publicKey": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n" }
|
|
103
|
+
],
|
|
104
|
+
"allowCapabilities": ["readsCredentials"],
|
|
105
|
+
"isolation": "process"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- `trustAnchors` accepts the array-of-`{keyId, publicKey}` form above or a
|
|
112
|
+
`{ keyId: publicKey }` map. Key resolution is **trust-anchor-only** — a signer
|
|
113
|
+
key not listed here is `unknown-signer`, fail-closed.
|
|
114
|
+
- Prefer `isolation: "process"` (kernel-enforced capability denial; requires a
|
|
115
|
+
Node that enforces `--allow-net`) over the default `worker` where you can.
|
|
116
|
+
- `plugins.enabled: false` is a global kill-switch that refuses to construct any
|
|
117
|
+
plugin.
|
|
118
|
+
|
|
119
|
+
## 5. Rotate, pin, and revoke (curation lifecycle)
|
|
120
|
+
|
|
121
|
+
The operator owns the trust anchors — curate them deliberately:
|
|
122
|
+
|
|
123
|
+
- **Rotate a signer key.** `plugin-keygen` a new key, re-sign the plugin with it,
|
|
124
|
+
and **add** the new anchor to `trustAnchors` alongside the old one. Once every
|
|
125
|
+
deployed plugin is re-signed, remove the old anchor. Never silently drop an
|
|
126
|
+
anchor that live envelopes still depend on (that is a fail-closed outage).
|
|
127
|
+
- **Pin an exact build.** `auth.plugin.pin: { version?, entrySha256?, manifestSha256? }`
|
|
128
|
+
refuses anything but the pinned build — defense against a malicious update or a
|
|
129
|
+
rollback. Use the `entrySha256` that `plugin-sign` printed.
|
|
130
|
+
- **Set a version floor.** `auth.plugin.versionFloor: { "<pluginId>": "<min>" }`
|
|
131
|
+
refuses any version below the floor (anti-rollback) without pinning an exact
|
|
132
|
+
build.
|
|
133
|
+
- **Revoke.** `auth.plugin.revoked: { signerKeyIds?: [...], entrySha256?: [...] }`
|
|
134
|
+
denylists a compromised signer key or a specific bad build; revocation is
|
|
135
|
+
fail-closed at load. Revocation takes effect at the **next load** (a restart, or
|
|
136
|
+
the kill-switch to force-drop a live plugin) — a live revocation feed is future
|
|
137
|
+
work (P1-SEC-025 residual).
|
|
138
|
+
|
|
139
|
+
## 6. Operator checklist
|
|
140
|
+
|
|
141
|
+
- [ ] Private signing key stored offline / in a secret store, never on the gateway host.
|
|
142
|
+
- [ ] Only the public key is in `trustAnchors`; `allowCapabilities` is the minimal set.
|
|
143
|
+
- [ ] Envelope verified with `plugin-verify` (matching `--core-version`) before deploy.
|
|
144
|
+
- [ ] A rotation plan exists (add-new-then-remove-old) and `pin`/`versionFloor` are set for production.
|
|
145
|
+
- [ ] `isolation: "process"` on a `--allow-net`-enforcing Node where possible.
|
|
146
|
+
|
|
147
|
+
See also: [`configuration.md`](configuration.md#authplugin-signed-authprovider-sandbox),
|
|
148
|
+
[`threat-model.md`](threat-model.md), [`api-stability.md`](api-stability.md).
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Haechi 리스크 레지스터 및 릴리스 게이트
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document(core 1.
|
|
3
|
+
- 문서 상태: Living document(core 1.4.x 추적)
|
|
4
4
|
- 작성일: 2026-06-16
|
|
5
|
-
- 기준 버전: 1.
|
|
5
|
+
- 기준 버전: 1.4.x
|
|
6
6
|
- 기준 브랜치: `main`
|
|
7
7
|
|
|
8
8
|
## 1. 현재 판단
|
|
@@ -14,9 +14,9 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
|
|
|
14
14
|
| 구분 | 판단 | 이유 |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| GitHub public | 허용 | 보안 한계, threat model, shared responsibility가 문서화됨 |
|
|
17
|
-
| GitHub release/tag | 허용 (`v1.
|
|
18
|
-
| npm stable | `haechi@1.
|
|
19
|
-
| production use | 운영자 게이트; `1.
|
|
17
|
+
| GitHub release/tag | 허용 (`v1.4.0` 릴리스됨) | `v1.4.0`이 현재 릴리스(additive minor — signed-plugin 저작 CLI); §5.7 / §5.8 항목은 모두 Resolved 유지, G9/G10/G11은 Pass |
|
|
18
|
+
| npm stable | `haechi@1.4.0` publish됨 | `1.4.0`은 보안 교정된 `1.3.x` 기준 위에 `plugin-keygen`/`plugin-sign`/`plugin-verify` CLI를 더한 attested OIDC publish; config/API 파괴 없음(`configVersion`은 `1` 유지) |
|
|
19
|
+
| production use | 운영자 게이트; `1.4.0`로 업그레이드 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; 운영자는 민감한 제3자 업스트림 트래픽을 프록시로 라우팅하기 전에 최신 `haechi@1.4.0`(모든 `1.3.x` 보안 수정 + 플러그인 저작 CLI 포함)을 실행해야 함 |
|
|
20
20
|
|
|
21
21
|
## 2. 릴리스 게이트
|
|
22
22
|
|
|
@@ -33,6 +33,7 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
|
|
|
33
33
|
| G8 | 1.3.0 백엔드 + 탐지 커버리지 확장 | **Anthropic Messages API**(`/v1/messages`, content-block + SSE `delta.text`, `event:` 라인 보존 재직렬화)와 **Google Gemini API**(model-in-path `:generateContent`/`:streamGenerateContent`, 기존 정확-매칭 어댑터를 바이트 동일하게 두는 additive `:method`-suffix 라우트 매처) 프로토콜 어댑터 추가; 탐지 커버리지 확장 — 클라우드/SaaS provider 키(OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored)와 국제 PII(FR/ES/JP + IT/SG/IN/DE/NL 국가 ID, 체크섬 validator), 각 하드블록-대-dial-eligible 결정은 측정된 충돌률 기반(하드블록은 비숫자 앵커 또는 비현실적으로 드문 형태가 필요; 흔한 길이의 bare-digit run은 allowlist로 정리 가능 유지); `bench:throughput` proxy 부하 벤치; `haechi-ratelimit-redis` 공유 저장소 rate-limiter 위성(WS3 시임의 운영 소비자; proxy가 이제 `rateLimiter.allow`를 `await`); `haechi-dashboard`가 요청별 `correlationId` 노출. 모든 변경은 additive — 새 `target.type`/탐지타입/`privacy.profile` *값*이며 새 config 키가 아님(`configVersion`은 `1` 유지); `tests/api-contract.test.mjs` 통과; core는 zero runtime dependency 유지; core 1.3.0 bump(additive 마이너) | Pass |
|
|
34
34
|
| G9 | 2026-06-16 전체 코드리뷰 보완 게이트 (1.3.1로 발행) | `P0-CR-001` 및 `P1-CR-002`부터 `P1-CR-005`까지 해결 또는 책임자 명시 수용; P2 항목은 해결 또는 명시적 non-blocking 근거와 일정 기록; 연결된 등록부 갱신. **13개 `P*-CR-*` 항목이 모두 Resolved이며(§5.7) `haechi@1.3.1`(2026-06-16, attested OIDC publish)로 발행되었습니다; core가 1.3.0 → 1.3.1로 bump(patch, 보완 전용 — API/config 표면 변경 없음, `configVersion`은 `1` 유지)되었습니다.** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
35
35
|
| G10 | 2026-06-16 코드리뷰 round 2 (CR2) 보완 게이트 | CR2 등록부(`code-review-risk-register-2026-06-16-round2.md`, §5.8)는 **P0/P1을 발견하지 못했습니다**; 세 개의 P2(`CR2-001` 프록시 upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply 경계)와 P3 묶음(`CR2-004..008`)이 모두 **Resolved이며 `haechi@1.3.2`로 발행되었고**(`CR2-009` won't-fix, `CR2-010` accepted) 연결된 등록부가 갱신되었습니다. | Pass (`haechi@1.3.2`, 2026-06-16) |
|
|
36
|
+
| G11 | 1.4.0 signed-plugin 저작 CLI | 1.0 Ed25519 trust gate를 위한 1차 저작 CLI — `plugin-keygen`(개인키 `0600`, 공개키 = trust anchor), `plugin-sign`(정확한 entry 바이트 바인딩), `plugin-verify`(런타임 동등 검증, fail-closed, `--allow-capability`); 개인키가 stdout/audit로 유출되지 않음; 적대적 검증 완료; `plugin-signing-and-trust.md` 큐레이션 런북이 P1-SEC-025 "운영자가 앵커를 큐레이션해야 함" 잔여를 해소. additive CLI 표면(config/API 파괴 없음, `configVersion`은 `1` 유지); `tests/api-contract.test.mjs` green; 코어는 zero runtime dependency 유지; 코어 1.3.3 → 1.4.0(additive minor)로 bump. | Pass (`haechi@1.4.0`, 2026-06-17) |
|
|
36
37
|
|
|
37
38
|
## 3. P0 배포 차단 리스크 상태
|
|
38
39
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Risk Register and Release Gates
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.4.x)
|
|
4
4
|
- Date: 2026-06-16
|
|
5
5
|
- Target version: 1.3.x
|
|
6
6
|
- Branch: `main`
|
|
@@ -14,9 +14,9 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
14
14
|
| Category | Judgment | Rationale |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| GitHub public | Allowed | Security limitations, threat model, and shared responsibility are documented |
|
|
17
|
-
| GitHub release/tag | Allowed (`v1.
|
|
18
|
-
| npm stable | `haechi@1.
|
|
19
|
-
| Production use | Operator-gated; upgrade to `1.
|
|
17
|
+
| GitHub release/tag | Allowed (`v1.4.0` released) | `v1.4.0` is the current release (additive minor — the signed-plugin authoring CLI); all §5.7 / §5.8 findings remain Resolved and G9/G10/G11 are Pass |
|
|
18
|
+
| npm stable | `haechi@1.4.0` published | `1.4.0` is an attested OIDC publish adding the `plugin-keygen`/`plugin-sign`/`plugin-verify` CLI over the security-remediated `1.3.x` baseline; no config/API break (`configVersion` stays `1`) |
|
|
19
|
+
| Production use | Operator-gated; upgrade to `1.4.0` | Supported only with operator network controls, authz/authn, and key custody; operators should run the latest `haechi@1.4.0` (it carries every `1.3.x` security fix plus the plugin authoring CLI) before routing sensitive third-party upstream traffic through the proxy |
|
|
20
20
|
|
|
21
21
|
## 2. Release Gates
|
|
22
22
|
|
|
@@ -33,6 +33,7 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
33
33
|
| 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 |
|
|
34
34
|
| G9 | 2026-06-16 full code-review remediation gate (shipped in 1.3.1) | `P0-CR-001` and `P1-CR-002` through `P1-CR-005` resolved or formally accepted; P2 items either resolved or scheduled with explicit non-blocking rationale; linked register updated. **All 13 `P*-CR-*` findings are Resolved (§5.7) and shipped in `haechi@1.3.1` (2026-06-16, attested OIDC publish); core bumped 1.3.0 → 1.3.1 (patch, remediation-only — no API/config surface change, `configVersion` stays `1`).** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
35
35
|
| G10 | 2026-06-16 code-review round 2 (CR2) remediation gate | The CR2 register (`code-review-risk-register-2026-06-16-round2.md`, §5.8) found **no P0/P1**; its three P2s (`CR2-001` proxy upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply bound) plus the P3 cluster (`CR2-004..008`) are all **Resolved and shipped in `haechi@1.3.2`** (`CR2-009` won't-fix, `CR2-010` accepted) and the linked register is updated. | Pass (`haechi@1.3.2`, 2026-06-16) |
|
|
36
|
+
| G11 | 1.4.0 signed-plugin authoring CLI | First-party CLI for the 1.0 Ed25519 trust gate — `plugin-keygen` (private key `0600`, public key = trust anchor), `plugin-sign` (binds the exact entry bytes), `plugin-verify` (runtime-equivalent verification, fail-closed, `--allow-capability`); no private-key leak to stdout/audit; adversarially verified; the `plugin-signing-and-trust.md` curation runbook closes the P1-SEC-025 "operator must curate anchors" residual. Additive CLI surface (no config/API break, `configVersion` stays `1`); `tests/api-contract.test.mjs` green; core stays zero runtime dependency; core bumped 1.3.3 → 1.4.0 (additive minor). | Pass (`haechi@1.4.0`, 2026-06-17) |
|
|
36
37
|
|
|
37
38
|
## 3. P0 Distribution-Blocking Risk Status
|
|
38
39
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createPrivateKey, generateKeyPairSync } from "node:crypto";
|
|
3
5
|
import { readAuditSummary, verifyAuditChain } from "../../audit/index.mjs";
|
|
4
6
|
import { DEFAULT_PROXY_PORT, HAECHI_VERSION, createHaechiProxy } from "../../proxy/index.mjs";
|
|
5
7
|
import { signPolicyBundleFile, verifyPolicyBundleFile } from "../../policy-bundle/index.mjs";
|
|
6
|
-
import { validatePluginManifestFile } from "../../plugin/index.mjs";
|
|
8
|
+
import { PluginLoadError, signPluginManifest, validatePluginManifestFile, verifySignedPlugin } from "../../plugin/index.mjs";
|
|
7
9
|
import { runMcpStdioFilter, wrapMcpChild } from "../../mcp-stdio/index.mjs";
|
|
8
10
|
import { addToken, listTokens, revokeToken } from "../../auth/index.mjs";
|
|
9
11
|
import { createLocalCryptoProvider } from "../../crypto/index.mjs";
|
|
@@ -51,6 +53,15 @@ try {
|
|
|
51
53
|
case "plugin-validate":
|
|
52
54
|
await pluginValidateCommand(argv);
|
|
53
55
|
break;
|
|
56
|
+
case "plugin-keygen":
|
|
57
|
+
await pluginKeygenCommand(argv);
|
|
58
|
+
break;
|
|
59
|
+
case "plugin-sign":
|
|
60
|
+
await pluginSignCommand(argv);
|
|
61
|
+
break;
|
|
62
|
+
case "plugin-verify":
|
|
63
|
+
await pluginVerifyCommand(argv);
|
|
64
|
+
break;
|
|
54
65
|
case "mcp-stdio":
|
|
55
66
|
await mcpStdioCommand(argv);
|
|
56
67
|
break;
|
|
@@ -472,6 +483,251 @@ async function pluginValidateCommand(argv) {
|
|
|
472
483
|
}
|
|
473
484
|
}
|
|
474
485
|
|
|
486
|
+
// plugin-keygen — generate an Ed25519 keypair for signing plugin envelopes. The
|
|
487
|
+
// PRIVATE key is written PKCS8 PEM at 0600 (operator-readable only); the PUBLIC
|
|
488
|
+
// key is written SPKI PEM (this is the trust anchor an operator pastes into
|
|
489
|
+
// auth.plugin.trustAnchors). The JSON output carries ONLY non-secret fields plus
|
|
490
|
+
// the PATH to the private key — never the private key material itself.
|
|
491
|
+
async function pluginKeygenCommand(argv) {
|
|
492
|
+
const options = parseOptions(argv);
|
|
493
|
+
const keyId = typeof options["key-id"] === "string" ? options["key-id"] : "haechi-plugin-signer";
|
|
494
|
+
const outDir = typeof options["out-dir"] === "string" ? options["out-dir"] : ".";
|
|
495
|
+
|
|
496
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
497
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
|
498
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
|
499
|
+
|
|
500
|
+
const privateKeyPath = join(outDir, `${keyId}.key`);
|
|
501
|
+
const publicKeyPath = join(outDir, `${keyId}.pub`);
|
|
502
|
+
|
|
503
|
+
await mkdir(outDir, { recursive: true });
|
|
504
|
+
// Restrictive mode on the private key: written 0600 so it is not group/world
|
|
505
|
+
// readable. (mkdir above is best-effort for "." which always exists.)
|
|
506
|
+
await writeFile(privateKeyPath, privateKeyPem, { mode: 0o600 });
|
|
507
|
+
await writeFile(publicKeyPath, publicKeyPem);
|
|
508
|
+
|
|
509
|
+
writeJson({
|
|
510
|
+
ok: true,
|
|
511
|
+
command: "plugin-keygen",
|
|
512
|
+
keyId,
|
|
513
|
+
privateKeyPath,
|
|
514
|
+
publicKeyPath,
|
|
515
|
+
publicKeyPem
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// plugin-sign — Ed25519-sign a plugin envelope. The entry bytes are read as RAW
|
|
520
|
+
// bytes (no transcoding) so entrySha256 binds the exact on-disk plugin source.
|
|
521
|
+
// The private key is read from a FILE (never from argv — a key on the command
|
|
522
|
+
// line leaks into process args / shell history). Output is the signed envelope
|
|
523
|
+
// JSON; the JSON status print never includes private material.
|
|
524
|
+
async function pluginSignCommand(argv) {
|
|
525
|
+
const [entryPath, ...rest] = argv;
|
|
526
|
+
if (!entryPath || entryPath.startsWith("--")) {
|
|
527
|
+
throw new Error("plugin-sign requires an entry file path");
|
|
528
|
+
}
|
|
529
|
+
const options = parseOptions(rest);
|
|
530
|
+
|
|
531
|
+
const required = {
|
|
532
|
+
key: "--key <private-key.pem>",
|
|
533
|
+
"signer-key-id": "--signer-key-id <id>",
|
|
534
|
+
"plugin-id": "--plugin-id <id>",
|
|
535
|
+
kind: "--kind <kind>",
|
|
536
|
+
"plugin-version": "--plugin-version <v>",
|
|
537
|
+
"core-range": "--core-range <range>"
|
|
538
|
+
};
|
|
539
|
+
for (const [flag, usage] of Object.entries(required)) {
|
|
540
|
+
if (typeof options[flag] !== "string" || options[flag].length === 0) {
|
|
541
|
+
throw new Error(`plugin-sign requires ${usage}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Read the EXACT entry bytes (Buffer, no utf8 transcoding) so the signed
|
|
546
|
+
// entrySha256 binds the real source.
|
|
547
|
+
const entryBytes = await readFile(entryPath);
|
|
548
|
+
// Read the private key from the file, not from argv.
|
|
549
|
+
const privateKey = createPrivateKey(await readFile(options.key, "utf8"));
|
|
550
|
+
|
|
551
|
+
const capabilities = await parseCapabilitiesOption(options.capabilities);
|
|
552
|
+
const notBefore = parseOptionalEpochMs(options["not-before"], "--not-before");
|
|
553
|
+
const notAfter = parseOptionalEpochMs(options["not-after"], "--not-after");
|
|
554
|
+
|
|
555
|
+
const pluginId = options["plugin-id"];
|
|
556
|
+
const signed = signPluginManifest(
|
|
557
|
+
{
|
|
558
|
+
pluginId,
|
|
559
|
+
kind: options.kind,
|
|
560
|
+
version: options["plugin-version"],
|
|
561
|
+
capabilities,
|
|
562
|
+
coreVersionRange: options["core-range"],
|
|
563
|
+
entryBytes,
|
|
564
|
+
notBefore,
|
|
565
|
+
notAfter
|
|
566
|
+
},
|
|
567
|
+
privateKey,
|
|
568
|
+
options["signer-key-id"]
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
const outPath = typeof options.out === "string" ? options.out : `${pluginId}.signed.json`;
|
|
572
|
+
await writeFile(outPath, `${JSON.stringify(signed, null, 2)}\n`);
|
|
573
|
+
|
|
574
|
+
writeJson({
|
|
575
|
+
ok: true,
|
|
576
|
+
command: "plugin-sign",
|
|
577
|
+
outPath,
|
|
578
|
+
pluginId,
|
|
579
|
+
signerKeyId: signed.signerKeyId,
|
|
580
|
+
entrySha256: signed.payload.entrySha256,
|
|
581
|
+
kind: signed.payload.kind,
|
|
582
|
+
version: signed.payload.version
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// plugin-verify — verify a signed plugin envelope against the exact entry bytes
|
|
587
|
+
// and operator trust anchors. Anchors come from EITHER an explicit --anchor PEM
|
|
588
|
+
// (+ --anchor-key-id, defaulting to the envelope's signerKeyId) OR a --config
|
|
589
|
+
// file's auth.plugin.trustAnchors. On success prints valid:true; on a
|
|
590
|
+
// PluginLoadError it FAILS CLOSED — the error propagates to main()'s catch, the
|
|
591
|
+
// reason code is printed to stderr, and the process exits non-zero (the gate
|
|
592
|
+
// signal). Never prints private material.
|
|
593
|
+
async function pluginVerifyCommand(argv) {
|
|
594
|
+
const [signedPath, ...rest] = argv;
|
|
595
|
+
if (!signedPath || signedPath.startsWith("--")) {
|
|
596
|
+
throw new Error("plugin-verify requires a signed envelope JSON file path");
|
|
597
|
+
}
|
|
598
|
+
const options = parseOptions(rest);
|
|
599
|
+
if (typeof options.entry !== "string" || options.entry.length === 0) {
|
|
600
|
+
throw new Error("plugin-verify requires --entry <entry-file>");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const signed = JSON.parse(await readFile(signedPath, "utf8"));
|
|
604
|
+
const entryBytes = await readFile(options.entry);
|
|
605
|
+
|
|
606
|
+
const trustAnchors = await resolvePluginTrustAnchors(options, signed);
|
|
607
|
+
|
|
608
|
+
const coreVersion = typeof options["core-version"] === "string" ? options["core-version"] : null;
|
|
609
|
+
const pin = typeof options.pin === "string" ? { entrySha256: options.pin } : null;
|
|
610
|
+
|
|
611
|
+
// --allow-capability <name> (repeatable) mirrors the OPERATOR capability
|
|
612
|
+
// allowlist that createRuntime passes at load time. It is REQUIRED to verify an
|
|
613
|
+
// authProvider envelope (core mandates such a plugin declare readsCredentials,
|
|
614
|
+
// which is not allowlisted by default) — without it, plugin-verify can only
|
|
615
|
+
// confirm a no-capability plugin. A bare flag (no value) is ignored.
|
|
616
|
+
const rawAllow = options["allow-capability"];
|
|
617
|
+
const allowCapabilities = (Array.isArray(rawAllow) ? rawAllow : [rawAllow])
|
|
618
|
+
.filter((value) => typeof value === "string" && value.length > 0);
|
|
619
|
+
|
|
620
|
+
// verifySignedPlugin throws a PluginLoadError on any refusal. We surface the
|
|
621
|
+
// stable .reason CODE (the gate signal) in the error message and re-throw so
|
|
622
|
+
// main()'s catch prints it and sets a non-zero exit code. A non-zero exit +
|
|
623
|
+
// the reason code is what a caller branches on (never a free-text message).
|
|
624
|
+
let payload;
|
|
625
|
+
try {
|
|
626
|
+
payload = verifySignedPlugin({
|
|
627
|
+
signed,
|
|
628
|
+
entryBytes,
|
|
629
|
+
trustAnchors,
|
|
630
|
+
coreVersion,
|
|
631
|
+
pin,
|
|
632
|
+
allowCapabilities
|
|
633
|
+
});
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (error instanceof PluginLoadError) {
|
|
636
|
+
throw new Error(`plugin-verify refused: ${error.reason} (${error.message})`);
|
|
637
|
+
}
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
writeJson({
|
|
642
|
+
ok: true,
|
|
643
|
+
command: "plugin-verify",
|
|
644
|
+
valid: true,
|
|
645
|
+
pluginId: payload.pluginId,
|
|
646
|
+
signerKeyId: signed.signerKeyId,
|
|
647
|
+
entrySha256: payload.entrySha256
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Resolve plugin trust anchors for plugin-verify. Precedence: an explicit
|
|
652
|
+
// --anchor PEM file (keyed by --anchor-key-id, defaulting to the envelope's
|
|
653
|
+
// signerKeyId) wins; otherwise --config supplies auth.plugin.trustAnchors. The
|
|
654
|
+
// config is read as RAW JSON (not normalizeConfig) so verifying an envelope does
|
|
655
|
+
// not require a full auth.provider:"plugin" config — only the anchors matter.
|
|
656
|
+
async function resolvePluginTrustAnchors(options, signed) {
|
|
657
|
+
if (typeof options.anchor === "string" && options.anchor.length > 0) {
|
|
658
|
+
const publicKeyPem = await readFile(options.anchor, "utf8");
|
|
659
|
+
const keyId = typeof options["anchor-key-id"] === "string" && options["anchor-key-id"].length > 0
|
|
660
|
+
? options["anchor-key-id"]
|
|
661
|
+
: signed?.signerKeyId;
|
|
662
|
+
if (typeof keyId !== "string" || keyId.length === 0) {
|
|
663
|
+
throw new Error("plugin-verify --anchor requires --anchor-key-id (or a signerKeyId in the envelope)");
|
|
664
|
+
}
|
|
665
|
+
return { [keyId]: publicKeyPem };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (typeof options.config === "string" && options.config.length > 0) {
|
|
669
|
+
const raw = JSON.parse(await readFile(options.config, "utf8"));
|
|
670
|
+
const anchors = raw?.auth?.plugin?.trustAnchors;
|
|
671
|
+
if (Array.isArray(anchors)) {
|
|
672
|
+
const map = {};
|
|
673
|
+
for (const anchor of anchors) {
|
|
674
|
+
if (!anchor || typeof anchor !== "object" || typeof anchor.keyId !== "string" || anchor.publicKey == null) {
|
|
675
|
+
throw new Error("each auth.plugin.trustAnchors entry must be { keyId, publicKey }");
|
|
676
|
+
}
|
|
677
|
+
map[anchor.keyId] = anchor.publicKey;
|
|
678
|
+
}
|
|
679
|
+
if (Object.keys(map).length === 0) {
|
|
680
|
+
throw new Error("auth.plugin.trustAnchors in --config is empty");
|
|
681
|
+
}
|
|
682
|
+
return map;
|
|
683
|
+
}
|
|
684
|
+
if (anchors && typeof anchors === "object") {
|
|
685
|
+
if (Object.keys(anchors).length === 0) {
|
|
686
|
+
throw new Error("auth.plugin.trustAnchors in --config is empty");
|
|
687
|
+
}
|
|
688
|
+
return anchors;
|
|
689
|
+
}
|
|
690
|
+
throw new Error("--config has no auth.plugin.trustAnchors to verify against");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
throw new Error("plugin-verify requires either --anchor <public-key.pem> or --config <haechi.config.json>");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Parse the --capabilities option: inline JSON, or @file pointing at a JSON
|
|
697
|
+
// file. Defaults to {} (an empty capability set). Must resolve to a plain
|
|
698
|
+
// object; anything else fails closed.
|
|
699
|
+
async function parseCapabilitiesOption(value) {
|
|
700
|
+
if (value === undefined || value === true) {
|
|
701
|
+
return {};
|
|
702
|
+
}
|
|
703
|
+
if (typeof value !== "string") {
|
|
704
|
+
throw new Error("--capabilities must be inline JSON or @file");
|
|
705
|
+
}
|
|
706
|
+
const text = value.startsWith("@") ? await readFile(value.slice(1), "utf8") : value;
|
|
707
|
+
let parsed;
|
|
708
|
+
try {
|
|
709
|
+
parsed = JSON.parse(text);
|
|
710
|
+
} catch (error) {
|
|
711
|
+
throw new Error(`--capabilities is not valid JSON: ${error.message}`);
|
|
712
|
+
}
|
|
713
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
714
|
+
throw new Error("--capabilities must be a JSON object");
|
|
715
|
+
}
|
|
716
|
+
return parsed;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Parse an optional epoch-ms flag value. Absent -> undefined (the signer treats
|
|
720
|
+
// it as null/unbounded). A present value must be an integer string.
|
|
721
|
+
function parseOptionalEpochMs(value, flag) {
|
|
722
|
+
if (value === undefined) {
|
|
723
|
+
return undefined;
|
|
724
|
+
}
|
|
725
|
+
if (typeof value !== "string" || !/^-?\d+$/.test(value.trim())) {
|
|
726
|
+
throw new Error(`${flag} must be an integer epoch-ms value`);
|
|
727
|
+
}
|
|
728
|
+
return Number(value);
|
|
729
|
+
}
|
|
730
|
+
|
|
475
731
|
async function authCommand(argv) {
|
|
476
732
|
const [sub, ...rest] = argv;
|
|
477
733
|
const options = parseOptions(rest);
|
|
@@ -759,6 +1015,21 @@ const COMMAND_HELP = {
|
|
|
759
1015
|
usage: "haechi plugin-validate <plugin-manifest.json>",
|
|
760
1016
|
summary: "Validate a plugin manifest (manifest-only; dynamic runtime is rejected)."
|
|
761
1017
|
},
|
|
1018
|
+
"plugin-keygen": {
|
|
1019
|
+
usage: "haechi plugin-keygen [--key-id haechi-plugin-signer] [--out-dir .]",
|
|
1020
|
+
summary: "Generate an Ed25519 plugin-signing keypair.",
|
|
1021
|
+
detail: "Writes the PKCS8-PEM private key to <out-dir>/<keyId>.key (0600) and the SPKI-PEM public key to <out-dir>/<keyId>.pub. The public key is the trust anchor an operator pastes into auth.plugin.trustAnchors. The private key is never printed — only its path."
|
|
1022
|
+
},
|
|
1023
|
+
"plugin-sign": {
|
|
1024
|
+
usage: "haechi plugin-sign <entry-file> --key <private-key.pem> --signer-key-id <id> --plugin-id <id> --kind <kind> --plugin-version <v> --core-range <range> [--capabilities <json|@file>] [--not-before <ms>] [--not-after <ms>] [--out <signed.json>]",
|
|
1025
|
+
summary: "Ed25519-sign a plugin envelope binding the exact entry bytes.",
|
|
1026
|
+
detail: "Reads the entry file as raw bytes (entrySha256 binds the real source) and the private key from the --key FILE (never argv). Writes the signed envelope to --out (default <plugin-id>.signed.json). Capabilities default to {}; provide inline JSON or @file. The private key is never printed."
|
|
1027
|
+
},
|
|
1028
|
+
"plugin-verify": {
|
|
1029
|
+
usage: "haechi plugin-verify <signed.json> --entry <entry-file> [--anchor <public-key.pem> --anchor-key-id <id>] [--config haechi.config.json] [--core-version <v>] [--pin <entrySha256>] [--allow-capability <name>]...",
|
|
1030
|
+
summary: "Verify a signed plugin envelope; fail closed on any refusal.",
|
|
1031
|
+
detail: "Resolves trust anchors from --anchor (with --anchor-key-id, default the envelope signerKeyId) or from --config auth.plugin.trustAnchors. Pass --allow-capability <name> (repeatable) to allowlist each declared capability — REQUIRED to verify an authProvider envelope (it must declare readsCredentials, which is not allowlisted by default). On success prints valid:true; on any refusal it exits non-zero with the PluginLoadError reason (the gate signal)."
|
|
1032
|
+
},
|
|
762
1033
|
"mcp-stdio": {
|
|
763
1034
|
usage: "haechi mcp-stdio [--config haechi.config.json]",
|
|
764
1035
|
summary: "Filter MCP JSON-RPC traffic on stdin/stdout (one direction)."
|
|
@@ -790,7 +1061,8 @@ function printHelp(topic) {
|
|
|
790
1061
|
"init", "protect", "report", "status", "audit-verify", "proxy",
|
|
791
1062
|
"policy-sign", "policy-verify",
|
|
792
1063
|
"token-reveal", "token-purge", "token-export",
|
|
793
|
-
"plugin-validate", "
|
|
1064
|
+
"plugin-validate", "plugin-keygen", "plugin-sign", "plugin-verify",
|
|
1065
|
+
"mcp-stdio", "mcp-wrap", "auth", "config"
|
|
794
1066
|
];
|
|
795
1067
|
const lines = order.map((name) => ` ${name.padEnd(16)}${COMMAND_HELP[name].summary}`);
|
|
796
1068
|
console.log(`Haechi — self-hosted AI context enforcement
|