haechi 0.9.0 → 1.1.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 +19 -12
- package/README.md +19 -12
- package/SECURITY.md +1 -1
- package/docs/README.md +1 -1
- package/docs/current/api-stability.ko.md +87 -41
- package/docs/current/api-stability.md +87 -41
- package/docs/current/configuration.ko.md +20 -1
- package/docs/current/configuration.md +20 -1
- package/docs/current/release-1.0-implementation-scope.ko.md +170 -0
- package/docs/current/release-1.0-implementation-scope.md +164 -0
- package/docs/current/release-1.1-implementation-scope.ko.md +128 -0
- package/docs/current/release-1.1-implementation-scope.md +128 -0
- package/docs/current/risk-register-release-gate.ko.md +26 -6
- package/docs/current/risk-register-release-gate.md +26 -6
- package/docs/current/threat-model.ko.md +22 -3
- package/docs/current/threat-model.md +22 -3
- package/package.json +7 -5
- package/packages/audit/index.mjs +13 -1
- package/packages/auth/index.mjs +173 -0
- package/packages/cli/bin/haechi.mjs +1 -1
- package/packages/cli/runtime.mjs +230 -5
- package/packages/core/index.mjs +19 -4
- package/packages/plugin/index.mjs +93 -17
- package/packages/plugin/process-sandbox.mjs +629 -0
- package/packages/plugin/sandbox-common.mjs +243 -0
- package/packages/plugin/sandbox.mjs +415 -0
- package/packages/plugin/signing.mjs +393 -0
- package/packages/ssrf/index.mjs +189 -0
|
@@ -141,10 +141,29 @@ Applies to `mcp-stdio` and `mcp-wrap`.
|
|
|
141
141
|
|
|
142
142
|
| Key | Type / values | Default | Notes |
|
|
143
143
|
|---|---|---|---|
|
|
144
|
-
| `auth.provider` | `none` \| `bearer` \| `external` | `none` | `none` = no auth (identity null). `bearer` = built-in token auth. `external` requires injecting an `authProvider` via `createRuntime(config, { authProvider })`. |
|
|
144
|
+
| `auth.provider` | `none` \| `bearer` \| `external` \| `plugin` | `none` | `none` = no auth (identity null). `bearer` = built-in token auth. `external` requires injecting an `authProvider` via `createRuntime(config, { authProvider })`. `plugin` = a signed `authProvider` sandbox (see [`auth.plugin`](#authplugin-signed-authprovider-sandbox)). |
|
|
145
145
|
| `auth.store` | path | `.haechi/auth.json` | Bearer token store (mode `0600`). Tokens are kept only as keyed-HMAC hashes; the plaintext is shown once by `haechi auth add`. |
|
|
146
146
|
| `auth.allowedLabelKeys` | string array | `["team", "env", "tier", "role"]` | Label keys a token may carry; values are length-limited and must not contain PII. |
|
|
147
147
|
|
|
148
|
+
### `auth.plugin` (signed authProvider sandbox)
|
|
149
|
+
|
|
150
|
+
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).
|
|
151
|
+
|
|
152
|
+
| Key | Type / values | Default | Notes |
|
|
153
|
+
|---|---|---|---|
|
|
154
|
+
| `auth.plugin.manifestPath` | path | — | The signed plugin manifest (`haechi.plugin.json`). |
|
|
155
|
+
| `auth.plugin.trustAnchors` | `[{keyId, publicKey}]` or `{ keyId: publicKey }` | — | Operator-allowlisted Ed25519 **public** keys. Key resolution is trust-anchor-only. |
|
|
156
|
+
| `auth.plugin.allowCapabilities` | string array | — | Capability allowlist; must include `readsCredentials`. A requested capability not listed → load refused. |
|
|
157
|
+
| `auth.plugin.isolation` | `worker` \| `process` | `worker` | `worker` = `worker_threads` (memory/crash isolation, **1.0**). `process` = a child under the Node permission model with **kernel-enforced** capability denial (**1.1**); requires a Node that enforces `--allow-net`. |
|
|
158
|
+
| `auth.plugin.timeoutMs` | positive int | — | Per-call timeout; on timeout the runtime terminates the child/worker and denies. |
|
|
159
|
+
| `auth.plugin.resourceLimits` | `{ maxOldGenerationSizeMb }` | — | **`worker` only** — `worker_threads` heap bound. N/A for `process`. |
|
|
160
|
+
| `auth.plugin.netEnforcement` | `require-permission` | `require-permission` | **`process` only** — network-containment policy. `require-permission` **fails closed** (refuses to construct) on a Node without `--allow-net`. |
|
|
161
|
+
| `auth.plugin.keyMaterial` | `{ url (https), ttlMs?, cooldownMs? }` | unset | **`process` only** — optional operator-declared key document the **host** fetches (SSRF-guarded, TTL+cooldown) and injects to a custom-credential plugin. The plugin never names a URL. |
|
|
162
|
+
| `auth.plugin.pin` | `{ version?, entrySha256?, manifestSha256? }` | unset | Exact-match pin (anti malicious-update / rollback). |
|
|
163
|
+
| `auth.plugin.revoked` | `{ signerKeyIds?, entrySha256? }` | unset | Revocation denylists (fail-closed at load). |
|
|
164
|
+
| `auth.plugin.versionFloor` | `{ <pluginId>: version }` | unset | Per-plugin minimum version (anti-rollback). |
|
|
165
|
+
| `auth.plugin.maxPendingCalls` / `maxMessageBytes` | positive int | `8` / `16384` | Concurrency + wire bounds (excess/oversized → deny). |
|
|
166
|
+
|
|
148
167
|
## `policy` profiles & limits
|
|
149
168
|
|
|
150
169
|
Per-client controls layered on top of the base `policy`. See [Named profiles](#named-profiles).
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Haechi 1.0 구현 범위
|
|
2
|
+
|
|
3
|
+
- 상태: Draft 0.2 (설계 — 아직 미구현; 2026-06-11 3-렌즈 적대적 보안 리뷰 후 강화)
|
|
4
|
+
- 날짜: 2026-06-11
|
|
5
|
+
- 목표 버전: 1.0.0 (0.9.0 다음)
|
|
6
|
+
- 유형: 안정 API 계약 + 플러그인 샌드박스 (첫 번째 안정 릴리스)
|
|
7
|
+
|
|
8
|
+
## 1. 릴리스 목표
|
|
9
|
+
|
|
10
|
+
1.0은 **첫 번째 안정 릴리스**다: (a) 지원 중단(deprecation)/마이그레이션 정책과 장기 감사 스키마를 갖춘 **안정적인 공개 API 계약을 동결**하고, (b) 0.1부터 의도적으로 유지해온 선을 넘는다 — **외부 플러그인 코드의 동적 로딩** — 단, **비대칭 서명, 기능(capability) 게이트, `worker_threads` 격리, 감사**가 갖춰진 샌드박스를 통해서만, 그리고 우선 **`authProvider`** 계약에 한해서만.
|
|
11
|
+
|
|
12
|
+
**범위 결정 (2026-06-11, 메인테이너 확인):**
|
|
13
|
+
|
|
14
|
+
1. **샌드박스/로딩 모델:** 동적 로딩은 **서명(Ed25519, 비대칭)**되고, **기능(capability) 매니페스트 allowlist + 운영자 pin/revocation 체크**를 통과하며, **`node:worker_threads` 격리** 경계에서 실행되고, 전체 **라이프사이클 감사**를 갖춘 플러그인에 한해서만 활성화된다. `createRuntime(config, providers)` **주입(injection)은 기본이자 권장 경로로 유지된다**.
|
|
15
|
+
2. **플러그인 범위:** 1.0에서는 **`authProvider` 전용**. Classifier/filter 및 crypto 플러그인은 1.x까지 주입 전용으로 유지.
|
|
16
|
+
3. **API 동결:** **엄격** — 핵심 공개 API, **provider 계약**, **감사 이벤트 스키마**(중첩 하위 스키마 포함), **config 스키마**가 엄격한 semver와 지원 중단 정책 하에 동결된다.
|
|
17
|
+
4. **릴리스 형태:** **단계적** — 1.0.0은 API 동결 + 서명된 플러그인 계약/적합성(conformance)/서명 + worker 격리 `authProvider` 샌드박스 MVP를 출시한다. 더 강력한 기능 **강제(enforcement)**(child-process + Node 권한 모델), 더 많은 플러그인 종류, 라이브 revocation 피드, 레지스트리는 1.x.
|
|
18
|
+
|
|
19
|
+
Core는 **zero runtime dependency**를 유지한다 — 샌드박스는 `node:worker_threads` + `node:crypto`(Ed25519 sign/verify는 `node:crypto` 내장) 위에 구축된다. `packages/policy-bundle`은 재사용하지 **않는다**(그것은 대칭 HMAC다 — §2.2 참조).
|
|
20
|
+
|
|
21
|
+
### 정직한 보안 모델 (먼저 읽을 것)
|
|
22
|
+
|
|
23
|
+
**`node:worker_threads`는 악성 코드에 대한 보안 샌드박스가 아니다.** worker는 프로세스를 공유하며 파일시스템, 네트워크, `process.env`에 여전히 접근할 수 있다; 격리는 **V8 힙 전용**이다(Node의 권한 모델은 프로세스 전체에 걸쳐 적용되며 worker별로 적용되지 않는다; `SharedArrayBuffer`/transferable은 공유 메모리 채널을 다시 열 수도 있으므로 와이어 형식은 일반 JSON 문자열이다 — §2.3). 따라서 1.0 샌드박스는 다음을 제공한다:
|
|
24
|
+
|
|
25
|
+
- **메모리 격리** — 별도의 V8 힙; 플러그인은 호스트 메모리, 암호화 키, 토큰 볼트, 감사 싱크를 읽거나 오염시킬 수 없다(타입이 지정된 메시지 채널만이 경계를 넘는다).
|
|
26
|
+
- **크래시/행(hang) 격리 + 리소스 제한** — `resourceLimits`(힙 상한) + 각 호출에 **worker를 종료시키는 타임아웃**이 버그가 있거나 폭주하는 플러그인을 억제한다; 행(hang)은 fail-closed(거부)로 처리된다.
|
|
27
|
+
- **데이터 최소화** — 호스트는 worker에게 **크리덴셜 슬라이스**(`Authorization` 헤더 / bearer 토큰)만 전송하며, **요청 바디와 암호화 키는 절대 전달하지 않는다**; worker는 **raw 클레임**을 반환하고, **호스트**가 `buildExternalIdentity`를 통해 PII-safe identity를 구축한다(keyed-HMAC 키는 호스트를 벗어나지 않는다).
|
|
28
|
+
- **좁고 감사된 타입이 지정된 계약** — worker는 `authProvider` 메시지 프로토콜만 사용하며; 모든 로드/거부/종료 결정이 감사된다(§2.4).
|
|
29
|
+
|
|
30
|
+
1.0에서 worker 경계가 보장하지 **않는** 것 — 이것들은 **수용된 잔여 위험으로, worker가 아닌 서명/검증 신뢰 모델에 의해서만 게이트된다**(§6):
|
|
31
|
+
|
|
32
|
+
- **악성 *서명된* 플러그인은 여전히 OS를 사용할 수 있다** — `fetch`, `fs`, `process.env`는 차단되지 않는다. 매니페스트의 `networkEgress: false`는 *선언*이며, 1.0에서 강제된 통제가 아니다.
|
|
33
|
+
- **악성 *서명된* auth 플러그인은 합법적으로 수신하는 라이브 크리덴셜을 유출할 수 있다**(bearer 토큰), 사실상 네트워크 egress를 갖기 때문이다. 1.0에는 **기술적 장벽이 없다** — 신뢰 게이트만 있을 뿐이다.
|
|
34
|
+
|
|
35
|
+
진정한 플러그인별 기능 **강제**(fs/net 차단, 크리덴셜 봉쇄)는 **child-process 격리와 Node 권한 모델**(`--permission --allow-fs-read=…`)이 필요하며, 이는 문서화된 **1.x** 경로다. 이것이 주입이 기본으로 유지되고 신뢰 게이트(비대칭 서명 + 운영자 allowlist + pin + revocation)가 핵심인 이유다.
|
|
36
|
+
|
|
37
|
+
## 2. 범위
|
|
38
|
+
|
|
39
|
+
### 2.1 API 안정성 동결 (1.0 계약)
|
|
40
|
+
|
|
41
|
+
**동결된 공개 표면 (명시적 IN/OUT 테이블이 오늘날의 모호한 "0.x는 preview" 표현을 대체한다).** 모든 `package.json` `exports` 서브패스와 CLI가 분류된다:
|
|
42
|
+
|
|
43
|
+
| 표면 | 1.0 상태 |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `haechi` / `haechi/core` (`createRuntime`, `createHaechi().protectJson`, `collectStringEntries`), `haechi/auth` (`authProvider` 계약, `buildExternalIdentity`, `buildIdentity`, `validateLabels`), `haechi/crypto` (`cryptoProvider` 계약, `assertCryptoProviderConformance`, `canonicalize`), `haechi/audit` (이벤트 스키마, `verifyAuditChain`, `sanitizeAudit`, `FORBIDDEN_KEYS`), `haechi/policy`, `haechi/filter` (룰 형태), `haechi/token-vault`, `haechi/runtime` (`normalizeConfig` 형태), `haechi/protocol-adapters`, `haechi/plugin` (매니페스트 + 신규 샌드박스) | **동결** (파괴적 변경 = major) |
|
|
46
|
+
| `haechi/proxy`, `haechi/mcp-stdio`, `haechi/stream-filter`, `haechi/policy-bundle`, `haechi/privacy-profiles`, 그리고 **CLI** (`bin/haechi.mjs`) | **동작 + wire/계약 동결**; 사람이 읽는 CLI/로그 **텍스트**는 여전히 변경 가능(계약 대상 아님) |
|
|
47
|
+
| `api-stability.md §3`에 아직 실험적으로 표시된 항목 | **졸업**(§3에서 제거)되거나 명시된 이유와 함께 **1.0 이후에도 명시적으로 preview로 유지** — 묵시적 모호함 없음 |
|
|
48
|
+
|
|
49
|
+
- **1.0부터 엄격한 semver** (파괴적 변경→major, 가산적 변경→minor, 수정→patch). core에 대한 "0.x minor는 파괴적 변경 가능" 여유가 끝난다.
|
|
50
|
+
- **지원 중단 정책.** 지원 중단된 export/필드/옵션은 **≥1 minor** 동안 유지되며, 문서화된 마이그레이션 노트와 **안정적인 `code` 접두사 `HAECHI_DEPRECATION_*`**가 있는 일회성 런타임 `process.emitWarning`을 방출하고(code/텍스트 자체도 계약의 일부), **다음 major**에서만 제거된다. **보안 예외(허용된 단 하나의 minor 내 파괴적 변경):** *공개된* 취약점을 닫기 위한 변경은 보안 권고문 + 마이그레이션 경로와 함께 **minor 내에서** 파괴적 변경/제거가 가능하다(기존 "안전하지 않은 config 차단은 패치에서 강화될 수 있다" 여유를 반영).
|
|
51
|
+
- **감사 이벤트 스키마 — 중첩 하위 스키마를 포함하여 동결**, 열거됨(최상위 레벨만이 아님): 최상위 `{id, timestamp, protocol, operation, identity, profile, mode, enforced, blocked, payloadShapeHash, detections, summary, auditIntegrity}`; `detections[].{type, ruleId, path, kind, confidence, action, enforced}`; **`identity.{id, type, subjectHash, issuerHash, provider}`**(PII-safe 프로젝션 — `scopes`/`labels`/raw subject는 감사 identity에 **포함되지 않음**); `summary.{byType, byAction, detectionCount}`; `auditIntegrity.{alg, canonicalization, sequence, previousHash, eventHash}`. **새 필드는 가산적으로만 추가되며 기존 필드의 정규화에 절대 영향을 미치지 않으므로**, 1.x 이벤트는 1.0 `verifyAuditChain`으로도 검증된다(이는 `canonicalize`가 리터럴 객체를 해시하고 검증기가 *동일하게 저장된 객체*를 재계산하기 때문에 유효하다 — 보장의 의미는 "미래에 가산적으로 추가된 필드가 새 레코드를 읽는 구버전 검증기를 깨뜨리지 않는다"는 것으로, 이전의 두루뭉술한 표현보다 정확하게 명시됨). 정규화 변경은 새 `canonicalization` 태그 + 리더 마이그레이션 경로와 함께 **major** 이벤트 스키마 bump다. 소비자가 파싱 없이 분기할 수 있도록 명시적 최상위 **`schemaVersion`**을 추가한다(리더 대면; 가산적).
|
|
52
|
+
- **Config 스키마 동결 단위:** config **키 존재 + 형태**가 동결됨; **기본값은 여전히 강화될 수 있음**(더 안전한 기본값은 파괴적 변경이 아님). 알 수 없는 키는 여전히 throw(fail-closed).
|
|
53
|
+
|
|
54
|
+
### 2.1a 위성 호환성 전제 조건 (core 1.0.0 bump 전에 반드시 완료)
|
|
55
|
+
|
|
56
|
+
네 위성 모두 `"haechi": ">=0.8.0 <1.0.0"`을 pin하고 있다 — 그리고 `<1.0.0`은 `1.0.0`을 **제외한다**(심지어 `1.0.0-rc.x`도). core를 1.0.0으로 bump하면 **모든 위성의 peer dependency를 충족 불가 상태로 만든다**(ERESOLVE / unmet peer). `haechi-auth-oidc`도 크로스-위성 동일 문제가 있다(`"haechi-auth-jwt": ">=0.2.0 <1.0.0"`). 따라서 **PR0**(어떤 core bump보다도 먼저):
|
|
57
|
+
|
|
58
|
+
- 모든 위성의 peer 범위를 다음 minor가 아닌 core **major**를 추적하도록 확장: `"haechi": ">=0.8.0 <2.0.0"`(동결의 정의상 유효 — ≥0.8로 빌드된 위성은 전체 1.x 라인에서 동작함), 그리고 `haechi-auth-oidc`의 `"haechi-auth-jwt": ">=0.2.0 <2.0.0"`. 네 위성 모두 패치 릴리스(`auth-jwt 0.2.x`, `crypto-kms 0.2.x`, `dashboard 0.1.x`, `auth-oidc 0.1.x`) + lockfile 재생성(workspace-lockfile 규칙 적용).
|
|
59
|
+
- `release:preflight` **게이트** 추가: 모든 `satellites/*/package.json` peer 범위를 파싱하여 발행할 core 버전에 대해 `semver.satisfies(coreVersion, range)`를 단언 — 미래의 core major가 위성이 여전히 제외하는 상태에서 출시되는 일을 방지.
|
|
60
|
+
- `api-stability.md §5`에 문서화: 위성 peer **상한은 core MAJOR를 추적**하며, 다음 minor 미만으로 pin되지 않는다.
|
|
61
|
+
|
|
62
|
+
### 2.2 비대칭 서명 플러그인 계약 (Ed25519) + 핀닝 + revocation + 적합성(conformance)
|
|
63
|
+
|
|
64
|
+
**서명은 비대칭(Ed25519)이며, 대칭 `policy-bundle` HMAC이 아니다.** `policy-bundle`은 로컬 AES 키 파일로 keyed된 HMAC으로 서명한다 — 검증기가 서명하는 것과 동일한 비밀을 보유하므로 "제3자 저자가 서명하고 운영자가 공개 키로 검증한다"는 표현을 할 수 없다. 1.0은 **`node:crypto` Ed25519** 서명 매니페스트 프리미티브를 추가한다(새 의존성 없음): **저자가 Ed25519 개인 키를 보유**; **운영자가 Ed25519 공개 키를 신뢰 앵커(trust anchor)로 allowlist**. (플러그인 서명에 `policy-bundle`을 재사용하지 말 것.)
|
|
65
|
+
|
|
66
|
+
- **서명 봉투는 경로가 아닌 콘텐츠를 커버한다.** 서명 바이트는 `canonicalize({ pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter })` — 즉 서명은 **정확한 엔트리 바이트의 sha256**, **kind**, **선언된 capabilities**, **호환 가능한 core 범위**, **유효 기간**을 바인딩한다. 경로에 서명하거나(`entrySha256`/`kind`/`capabilities`를 생략하는 것)는 교체(swap)/capability 다운그레이드 공격이 되므로 거부된다.
|
|
67
|
+
- **신뢰 앵커 전용 키 해석 (kid-by-claim 없음).** 검증 키는 **오직** 운영자의 `trustAnchors` allowlist에서만 해석된다; `manifest.signerKeyId`가 allowlist된 앵커에 없으면 **검증 전에 거부**한다. 알고리즘은 앵커별로 Ed25519로 고정된다(alg 민첩성 없음, HS/RS 혼동 없음). 플러그인 신뢰 앵커 세트는 **별도의 큐레이션된 목록**이며, AES 로테이션 키 파일과 절대 혼용하지 않는다(만료/로테이션된 AES kid가 서명자 앵커가 되어서는 안 됨).
|
|
68
|
+
- **핀닝 (악성 업데이트 방지 / 롤백 방지).** 운영자 config `plugin.pin = { version?, entrySha256?, manifestSha256? }`: 로더는 로드된 매니페스트 버전/엔트리 해시가 pin과 일치하지 않으면 fail-closed. **`pluginId`별 버전 플로어**는 이전 서명된 아티팩트로의 롤백을 거부한다. 따라서 *신뢰된 서명자*도 pin/플로어를 트리거하지 않고는 동일 앵커 하에 새(또는 구버전 취약한) 엔트리를 조용히 출시할 수 없다.
|
|
69
|
+
- **Revocation + 최신성.** 운영자 denylist `plugin.revokedSignerKeyIds` + `plugin.revokedEntrySha256`은 로드 시 확인됨(fail-closed: 취소된 서명자 또는 해시는 절대 로드되지 않음). 서명된 `notBefore`/`notAfter` 기간은 로드 시 강제됨. **메모리 내 revocation 동작 (1.0, 솔직하게 명시):** revocation은 **다음 로드/재시작 시**에 적용된다; **전역 kill-switch** (`plugins.enabled: false` 및 플러그인별 disable)로 운영자가 **라이브 플러그인을 즉시 강제 제거**할 수 있다. 라이브 CRL/피드는 1.x.
|
|
70
|
+
- **매 재시작(respawn)마다 재검증.** worker는 타임아웃-종료 후 지연 재시작되므로, **전체 게이트(서명 + 앵커 + pin + revocation + capability allowlist)가 최초 생성뿐 아니라 매 spawn마다 재실행**된다.
|
|
71
|
+
- **Capability allowlist (운영자 측).** `plugins.allowCapabilities`; 그 밖의 capability를 요청하는 매니페스트는 거부됨. `readsCredentials`는 `kind: authProvider`에서 **필수**다(bearer 토큰을 봄). `networkEgress`/`readsPlaintext`는 1.0에서 **선언되고 감사되지만 worker에 의해 강제되지 않는다**(§1 잔여 — 노출됨, 신뢰됨 아님).
|
|
72
|
+
- **적합성(conformance)은 정확성 게이트이지 악의 스크린이 아니다.** `assertAuthProviderConformance(provider, { now, vectors })`는 **샌드박스된** 플러그인을 열거된 보안 동작으로 실행한다: 크리덴셜 없음 → `null`; 형식 불량 크리덴셜 → `null`; 만료/아직 유효하지 않음(`now`를 통해 주입된 시각) → `null`; 내부 **throw는 호출자에게 `null`로 표면화**(절대 전파하지 않음); 반환된 identity는 반드시 `subjectHash`/`issuerHash`를 가져야 하며 raw 입력 subject/issuer와 동일한 필드를 **포함해서는 안 된다**(PII 안전성); 거부는 동일 입력에 대해 **결정론적**; 유효 크리덴셜 → 올바르게 형성된 PII-safe identity. 로더는 **이에 실패하는 플러그인의 연결을 거부한다**. 그러나 서명된 플러그인은 고정된 테스트를 감지하고 동작을 바꿀 수 있으므로: 적합성은 **로드마다 예측 불가능한 무작위 벡터**를 사용하며, — 핵심 — **호스트는 매 호출마다 PII 안전성을 재검증**한다(`buildExternalIdentity` + 아래 sanitizer가 요청별로 실행됨, 로드 시에만이 아님). **적합성 통과가 신뢰성을 의미하지 않는다**(그것은 서명+검증 게이트다); 테스트/프로덕션 분기는 수용된 잔여 위험이다(§6).
|
|
73
|
+
|
|
74
|
+
### 2.3 `worker-isolated` `authProvider` 샌드박스 (MVP)
|
|
75
|
+
|
|
76
|
+
`createSandboxedAuthProvider({ manifestPath, trustAnchors, allowCapabilities, pin, revoked, cryptoProvider, auditSink, timeoutMs, maxPendingCalls, maxMessageBytes, resourceLimits, now })`는 동결된 계약을 만족하는 **호스트 측 `authProvider`**를 반환한다 — 따라서 **기존** 주입 심(seam)과 새 `auth.provider: "plugin"` config 경로를 통해 연결된다.
|
|
77
|
+
|
|
78
|
+
- **로드 시퀀스 (모든 단계에서 fail-closed, 각 단계 감사됨):** 매니페스트 검증(`worker-isolated` + `kind: authProvider`) → `signerKeyId`를 **`trustAnchors`에서만** 앵커 해석(아니면 거부) → **엔트리 바이트를 메모리로 읽어** sha256하고 **`entrySha256`을 포함하는 정규 봉투에 대해 Ed25519 서명 검증** → `notBefore/notAfter`, revocation denylist, pin/version-floor, capabilities ⊆ allowlist 확인 → **검증된 인메모리 소스에서** Worker를 spawn(`new Worker(code, { eval: true, resourceLimits, workerData: <비밀 없음> })`), **검증 후 경로를 재해석하지 않음**(TOCTOU 없음; symlink된 엔트리 거부) → 샌드박스된 provider에 대해 `assertAuthProviderConformance` 실행 → 그 이후에만 라이브 provider 반환. 실패 시 생성에서 throw하고 `plugin.load.refused{reason}`을 방출함(§2.4).
|
|
79
|
+
- **요청별 프로토콜 (데이터 최소화, correlation-id 적용):** `authenticate(request)`는 **크리덴셜 슬라이스**(`Authorization` 헤더/토큰 — 바디 절대 아님)만 추출하고, **고유한 correlation id**로 래핑하여 **MessagePort를 통해 JSON 문자열**로 post한다(structured-clone 객체 없음, `SharedArrayBuffer`/transferable 없음 → 공유 메모리 또는 객체 그래프 밀수 없음). `maxMessageBytes`가 와이어를 제한한다. worker는 크리덴셜을 검증하고(JWKS egress는 auth 플러그인에 고유) **raw 클레임** `{ subject, issuer, type, scopes, labels }` 또는 거부를 반환한다.
|
|
80
|
+
- **호스트 측 클레임 sanitizer (`buildExternalIdentity` 전에):** JSON 응답은 **null-prototype 객체**로 파싱된다(`JSON.parse` + `Object.create(null)`로 재구성); **고정된 own-enumerable 키 allowlist**만 허용됨; `__proto__`/`constructor`/`prototype` 제거됨; 배열 크기와 전체 identity 크기 제한됨; 모든 값은 경계에서 타입 검증/강제됨. 이후 **호스트**가 PII-safe identity를 구축한다(`buildExternalIdentity({ provider: "plugin:<pluginId>", subject, issuer, type, scopes, labels }, cryptoProvider)`) — keyed-HMAC 키는 worker에 진입하지 않으며, 적대적 클레임 객체가 prototype을 오염시키거나 raw 값을 밀수할 수 없다.
|
|
81
|
+
- **동시성 모델 (호출자 간 누출 없음 / 종료 경쟁 없음):** 각 in-flight 호출은 **correlation id**로 응답과 매칭됨; 일치하지 않는/중복된/늦은 응답은 **삭제됨**. worker는 **단일 점유(single-occupancy)**(하나의 in-flight 호출) — 따라서 호출별 타임아웃-종료는 *형제* 호출을 절대 죽이지 않음; 대기 호출 **상한(`maxPendingCalls`)**이 동시성을 제한함(초과 → 거부). 종료 후 재시작은 **single-flight**으로 보호됨. 플러그인은 **호출 간 무상태(stateless)**여야 하며; 잔여 크로스-요청 상태 위험은 §6 잔여다.
|
|
82
|
+
- **타임아웃 + 리소스 제한 (fail-closed):** 각 호출은 `timeoutMs`(필수 양의 정수 — 무한 기본값 없음)로 제한됨; 타임아웃 시 호스트는 worker를 **종료**(`plugin.worker.terminated{cause: timeout}`)하고 `null`을 반환하며 지연 재시작함(전체 게이트 재실행). `resourceLimits`가 힙을 제한함. (CPU/fd/소켓은 1.0에서 제한되지 않음 — §6 잔여.)
|
|
83
|
+
- **Config (`auth.provider: "plugin"`) — 열거형 fail-closed `normalizeConfig` 규칙** (`keys`/`tokenVault` 엄격함과 일치): `plugin.manifestPath`(비어 있지 않은 로컬 경로) 필수; `plugin.trustAnchors` 비어 있지 않은 `{ keyId: string, publicKey: string (Ed25519) }` 배열; `plugin.allowCapabilities` `CAPABILITY_KEYS ∪ {readsCredentials}` 부분집합인 배열(알 수 없는 것 거부); `kind: authProvider`에 `readsCredentials` 존재; `plugin.timeoutMs` 양의 정수; `resourceLimits.maxOldGenerationSizeMb` 양의 정수; 선택적 `plugin.pin`/`plugin.revoked*`/version-floor 올바르게 형성됨; `plugins.enabled` 준수(kill-switch). 모든 위반은 로드 시 throw. `createRuntime`은 호스트 측 identity 구축을 위해 주입된 `cryptoProvider`를 여전히 필요로 한다.
|
|
84
|
+
|
|
85
|
+
### 2.4 플러그인 라이프사이클 감사 (보안 제품은 서드파티 코드 로딩을 반드시 기록해야 함)
|
|
86
|
+
|
|
87
|
+
기존 해시-체인 `auditSink`를 재사용하여(`recordProxyDecision`/`auth_denied`가 이미 사용하는 동일 심), 샌드박스는 **PII-safe** 이벤트를 방출한다 — id/해시/카운트만:
|
|
88
|
+
|
|
89
|
+
- `plugin.load.accepted` `{ pluginId, version, entrySha256, signerKeyId, capabilitiesGranted }`
|
|
90
|
+
- `plugin.load.refused` `{ reason ∈ missing-signature | unknown-signer | tampered-entry | revoked | below-version-floor | pin-mismatch | expired-window | capability-not-allowlisted | conformance-failed | manifest-invalid, pluginId?, signerKeyId? }`
|
|
91
|
+
- `plugin.authenticate.deny` `{ pluginId, reason ∈ deny | invalid-claims | timeout | over-capacity | oversized }`
|
|
92
|
+
- `deny` — 플러그인이 일반 거부를 반환함 (worker 하네스가 변환한 내부 throw 포함)
|
|
93
|
+
- `invalid-claims` — 호스트 측 클레임 sanitize 또는 `buildExternalIdentity` 거부 (이전 `non-pii-safe-identity` 레이블 통합)
|
|
94
|
+
- `timeout` — 호출별 타임아웃 만료; worker 종료 및 재시작
|
|
95
|
+
- `over-capacity` — `maxPendingCalls` 초과; worker 큐에 진입하기 전에 호출 거부
|
|
96
|
+
- `oversized` — 크리덴셜 메시지가 `maxMessageBytes`를 초과; worker에 전송되지 않음
|
|
97
|
+
- `plugin.worker.terminated` `{ pluginId, cause ∈ timeout | oom | crash }`
|
|
98
|
+
|
|
99
|
+
`FORBIDDEN_KEYS`는 플러그인/클레임 표면(`claims`, `subject`, `issuer`, `credential`, `authorization`, `signature`, `entry`)으로 **확장**된다 — 심층 방어로서, 미래의 플러그인 이벤트가 raw 클레임/토큰/서명자 비밀을 체인 로그에 절대 누출하지 못하도록(위의 이벤트는 이미 id/해시만 운반함). 테스트는 거부된 로드와 worker 타임아웃이 각각 정확히 하나의 체인 이벤트를 방출함을 단언하고, raw 클레임이 있는 합성 플러그인 이벤트가 `sanitizeAudit`에 의해 제거됨을 단언한다.
|
|
100
|
+
|
|
101
|
+
### 2.5 실제 환경 검증 종료 기준
|
|
102
|
+
|
|
103
|
+
- **충족됨:** 2026-06-11 실제 자체 호스팅 vLLM + Ollama([[2026-06-11-real-environment-validation]]) + `haechi-dashboard` 관측가능성에 대한 라이브 검증.
|
|
104
|
+
- **잔여 (문서화됨, 1.0 게이팅 아님):** (1) **라이브 KMS 백엔드 검증** (실제 AWS/GCP/Azure/Vault)은 CI 밖; (2) **worker 플러그인 샌드박스 자체는 실제 적대적 플러그인에 대해 미검증** — 보안은 신뢰 게이트 + §6 잔여에 기반하며, fail-closed/데이터 최소화 테스트로 검증됨(적대적 서드파티 플러그인 레드팀이 아닌 — 이상적으로는 child-process+permission 강제와 함께 1.x 과제).
|
|
105
|
+
|
|
106
|
+
## 3. 명시적 비범위 (1.x로 연기)
|
|
107
|
+
|
|
108
|
+
- 악성 서명된 플러그인에 대한 **Capability *강제*** (fs/net 차단, 크리덴셜 봉쇄) — child-process 격리와 Node 권한 모델이 필요.
|
|
109
|
+
- **Classifier/filter 및 crypto 플러그인 로딩** — 1.0에서는 `authProvider`만.
|
|
110
|
+
- **라이브 revocation 피드 / CRL**, 플러그인 **레지스트리 / 마켓플레이스**, multi-origin, 핫 리로드, **미서명 dev 로더** (신뢰 게이트를 훼손하게 됨 — 개발은 주입을 사용).
|
|
111
|
+
- **Python SDK.**
|
|
112
|
+
|
|
113
|
+
## 4. 하위 호환 & 1.0 안정성 계약
|
|
114
|
+
|
|
115
|
+
기존 동작은 **불변** — 모든 provider 계약, config와 (이제 중첩 열거된) 감사 스키마, zero-dependency 자세가 0.9와 정확히 동일하다; 이것들이 **동결로 선언된다**. 플러그인 샌드박스는 **순수 가산적이며 opt-in**이다(`auth.provider: "plugin"`; 기본값은 `none`/`bearer`/`external`로 유지). 하나의 동작적 core 변경은 **가산적 `FORBIDDEN_KEYS` 확장**(§2.4)과 **`schemaVersion`** 필드(가산적)다. **위성 peer 범위 확장(§2.1a)은 전제 조건**으로, 네 위성이 core 1.0.0에 대해 설치를 유지하도록 한다.
|
|
116
|
+
|
|
117
|
+
## 5. 1.0 관계 / 1.0이 닫는 것
|
|
118
|
+
|
|
119
|
+
1.0은 두 오랜 1.0 게이트를 닫는다 — **API 안정성 동결**(§2.1)과 **플러그인 샌드박스 + 동적 로딩 스토리**(§2.2–2.4: 비대칭 서명 + 격리 + 감사 + auth 전용) — 그리고 **실제 환경 검증** 종료 기준이 문서화된 잔여와 함께 충족됨을 기록한다(§2.5). Haechi를 개발자 preview에서 안정적인 자체 호스팅 보안 게이트웨이로 졸업시키면서 core 약속을 유지한다: 작고 zero-dependency인 core, 모든 곳에서 fail-closed, "컴포넌트를 교체해도 동일한 보안 테스트가 통과된다."
|
|
120
|
+
|
|
121
|
+
## 6. 위협 모델 & 리스크 레지스터 델타 (구체적)
|
|
122
|
+
|
|
123
|
+
| 신규 표면 (1.0) | 통제 | 잔여 |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| **악성/손상된 서명된 플러그인** 동적 로딩 | `entrySha256`+kind+capabilities에 대한 Ed25519 서명, 신뢰 앵커 전용 키 해석, pin + version-floor + revocation denylist, conformance 게이트, worker 메모리/크래시 격리, 전체 라이프사이클 감사 | **서명된 플러그인 자체의 fs/net/`process.env`는 차단되지 않으며, 수신하는 크리덴셜을 유출할 수 있다** — 서명/검증 신뢰 모델에 의해서만 게이트됨; 진정한 강제는 1.x child-process+permission 경로 |
|
|
126
|
+
| **플러그인으로의 PII/비밀 누출** | 크리덴셜 슬라이스만 전달됨(바디/키 절대 아님); JSON-string 와이어; null-proto sanitizer; 호스트가 keyed-HMAC identity 구축 | auth 플러그인이 합법적으로 검증하는 크리덴셜은 그것에 가시적임(위 행 참조) |
|
|
127
|
+
| **경계 간 객체/proto 밀수** | JSON-string 와이어(structured clone / SAB / transferable 없음) + `buildExternalIdentity` 전 null-proto allowlist sanitizer | 실질적 잔여 없음 |
|
|
128
|
+
| **엔트리 교체 / TOCTOU** | `entrySha256` 서명; 인메모리 읽기 + 해시 + 검증 + 인메모리 소스에서 spawn; 경로 재해석 없음; symlink 거부 | 실질적 잔여 없음 |
|
|
129
|
+
| **서명자 키 혼동 / 다운그레이드 / 롤백 / 악성 업데이트** | 신뢰 앵커 전용 해석, 알고리즘 고정, pin/version-floor, revocation | 운영자가 앵커/pin을 큐레이션해야 함 |
|
|
130
|
+
| **플러그인 DoS** | 호출별 `timeoutMs` 종료, 힙 `resourceLimits`, `maxPendingCalls`, `maxMessageBytes`, 단일 점유 worker | 서명된 플러그인이 타임아웃 내 할당된 CPU를 소진할 수 있음(CPU/fd는 1.0에서 제한되지 않음) |
|
|
131
|
+
| **미감사 코드 로드** | `plugin.load.*` / `authenticate.deny` / `worker.terminated` 감사 이벤트; 확장된 `FORBIDDEN_KEYS` | — |
|
|
132
|
+
| **적합성 테스트/프로덕션 분기** | 로드마다 무작위화된 벡터 + 호출별 호스트 PII 안전성 재검증 | 악성 플러그인이 적합성을 통과한 후 오동작 가능(서명+검증으로 커버되며 적합성으로 아님) |
|
|
133
|
+
| **API/감사 스키마 드리프트** | 엄격한 semver + 지원 중단 기간(+ 보안 예외) + 가산적 전용 중첩 열거 감사 스키마 + `schemaVersion` | major bump는 설계상 파괴적 변경 가능(문서화된 마이그레이션) |
|
|
134
|
+
|
|
135
|
+
제안 리스크 ID: **P1-SEC-024**(동적 플러그인 실행 / 샌드박스 신뢰 모델 — P1-SEC-004의 매니페스트 전용 입장을 새 통제 하에 수퍼세드, 해제됨), **P1-SEC-025**(플러그인 서명/신뢰 앵커/revocation 라이프사이클), **P2-API-001**(안정적 계약 동결 + 지원 중단 정책), **P2-OPS-006**(위성 peer 범위 / major 추적 게이트). 신규 §4 제외: 악성 서명된 플러그인에 대한 capability 강제, 크리덴셜 봉쇄, classifier/crypto 플러그인 로딩, 미서명 dev 로더, 라이브 CRL.
|
|
136
|
+
|
|
137
|
+
## 7. 테스트 기준 (PR 분해에 매핑)
|
|
138
|
+
|
|
139
|
+
### 7.1 PR0 — 위성 peer 범위 확장 + preflight 게이트
|
|
140
|
+
|
|
141
|
+
- 네 위성의 `haechi` peer 범위가 `>=0.8.0 <2.0.0`으로 확장됨(그리고 auth-oidc의 `haechi-auth-jwt`는 `<2.0.0`); lockfile 재생성; `release:preflight`가 위성의 범위가 `!semver.satisfies(coreVersionToPublish, satelliteRange)`이면 실패. 테스트가 core `1.0.0`을 시뮬레이션하고 모든 위성 범위가 충족됨을 단언.
|
|
142
|
+
|
|
143
|
+
### 7.2 PR1 — API 안정성 동결 (문서 + 계약 테스트)
|
|
144
|
+
|
|
145
|
+
- `api-stability.md`(+ko)가 IN/OUT 테이블, 엄격한 semver + 지원 중단 정책(`HAECHI_DEPRECATION_*` 런타임 경고 계약 및 보안 예외 포함), 위성 major 추적 규칙을 담음.
|
|
146
|
+
- **계약/스냅샷 테스트**가 서브패스별 동결된 export + **non-null `identity`와 하나의 `detections[]` 항목을 포함하는 전체 감사 이벤트**(중첩 하위 스키마가 최상위 레벨만이 아닌 것으로 보호됨) + config 스키마 키 세트 + `schemaVersion`을 pin함. 가산적 필드는 통과; 제거/이름 변경된 필드(최상위 또는 중첩)는 실패. `verifyAuditChain`이 동결 스키마 픽스처를 검증하고 합성 가산 필드가 있어도 여전히 검증함.
|
|
147
|
+
|
|
148
|
+
### 7.3 PR2 — Ed25519 서명 플러그인 계약 + 핀닝/revocation + 적합성 하네스
|
|
149
|
+
|
|
150
|
+
- `packages/plugin`이 Ed25519 봉투와 함께 `worker-isolated`+`authProvider` 매니페스트를 수락; **거부**(각각 `plugin.load.refused{reason}`를 방출하는 별개의 fail-closed 테스트): 누락/무효 서명; `trustAnchors`에 없는 서명자(kid-not-allowlisted, **검증 전**에 해석됨); **서명 후 엔트리 바이트 변조, 경로 변경 없음**; revoked 서명자 / revoked entryHash; version-floor 미달; pin 불일치; `notBefore/notAfter` 외부; capability allowlist에 없음; alg ≠ Ed25519.
|
|
151
|
+
- `assertAuthProviderConformance` 존재; 참조 provider 통과; 깨진 것(throw / raw-subject identity 반환 / 만료 크리덴셜 수락 / 비결정론적)이 각 경우마다 **실패**(네거티브 테스트). 벡터는 실행마다 무작위화됨.
|
|
152
|
+
- `FORBIDDEN_KEYS` 확장 테스트: `claims`/`credential`/`signature`가 있는 합성 플러그인 이벤트가 `sanitizeAudit`에 의해 제거됨; 체인이 유효한 상태로 유지됨.
|
|
153
|
+
|
|
154
|
+
### 7.4 PR3 — `worker-isolated` authProvider 샌드박스
|
|
155
|
+
|
|
156
|
+
- 참조 **서명된** auth 플러그인이 로드되어 worker 내에서 적합성 통과, 유효 bearer/JWT를 **호스트가 구축한 PII-safe identity**로 인증함; 단언: worker는 **크리덴셜 슬라이스만** 수신함(계측된 echo-plugin이 바디/감사 싱크/토큰 볼트/키를 절대 받지 못했음을 증명), raw subject가 감사에 나타나지 않음, `plugin.load.accepted`가 해석된 `entrySha256`/`signerKeyId`와 함께 방출됨.
|
|
157
|
+
- **Fail-closed + 격리 매트릭스:** 미서명/잘못된 서명자/변조/revoked/pin 불일치/capability-not-allowlisted → 생성 throw + `load.refused`; **타임아웃 → `null` + worker 종료 + `worker.terminated{timeout}`**; throw → `null`; `__proto__`/추가 키가 있는 클레임 객체 → sanitize됨(prototype 오염 없음, 추가 키 제거됨) 및 PII-safe; 별개의 correlation id를 가진 두 동시 호출이 절대 응답을 교차하지 않음; 하나의 호출 종료가 형제를 죽이지 않음(단일 점유); `maxPendingCalls`/`maxMessageBytes` 강제됨; `plugins.enabled:false`(kill-switch)가 로드를 거부함.
|
|
158
|
+
- `normalizeConfig` `auth.provider:"plugin"` 열거형 fail-closed 테스트(각 잘못된 옵션이 throw); `createRuntime` + proxy auth 게이트를 통한 end-to-end(요청이 플러그인을 통해 인증됨; identity keyed-HMAC; 감사에 raw subject/크리덴셜 없음).
|
|
159
|
+
|
|
160
|
+
### 7.5 전체
|
|
161
|
+
|
|
162
|
+
- Core가 zero runtime dependency를 유지함(`node:`만 — Ed25519는 `node:crypto`); `check:packaging` + `check:satellite-packaging` 통과; 동결된 계약 스냅샷 테스트 + peer 범위 preflight 게이트가 미래 PR을 보호함.
|
|
163
|
+
|
|
164
|
+
## 8. 제안 PR 분해 (스택)
|
|
165
|
+
|
|
166
|
+
1. **PR0 — 위성 peer 범위 확장 + preflight 게이트** (전제 조건; 네 위성 패치 릴리스). → §7.1
|
|
167
|
+
2. **API 동결** — `api-stability.md`(+ko) IN/OUT 테이블 + 지원 중단/보안 예외 정책 + 중첩 스키마 계약/스냅샷 테스트 + `schemaVersion`. → §7.2
|
|
168
|
+
3. **Ed25519 서명 플러그인 계약 + 적합성** — 비대칭 프리미티브(`node:crypto`), 서명 봉투(entryHash/kind/capabilities/기간), 신뢰 앵커 전용 해석, pin/version-floor/revocation, `assertAuthProviderConformance`, `FORBIDDEN_KEYS` 확장. → §7.3
|
|
169
|
+
4. **Worker 격리 authProvider 샌드박스** — `createSandboxedAuthProvider`(인메모리 검증 spawn, JSON-string 와이어, null-proto sanitizer, correlation-id 단일 점유 동시성, 타임아웃/종료, kill-switch), `auth.provider:"plugin"` config 분기 + 라이프사이클 감사, 참조 서명된 플러그인 + §7.4 매트릭스. → §7.4
|
|
170
|
+
5. **1.0.0 릴리스 컷** — core를 **1.0.0**으로 bump; docs EN/KO (이 범위 문서, 위협 모델 + §6 ID + 목표 버전 bump와 함께 리스크 레지스터 델타, 실제 환경 종료 기준 + 잔여); wiki ingest(`[[plugin-sandbox]]` 페이지 + `[[packaging-and-distribution]]`/`[[identity-and-auth]]`/`release-roadmap` 업데이트); README "Current Scope". Core는 `v*` 태그를 재사용; 첫 번째 안정 `haechi@1.0.0`이 증명(attested)과 함께 발행됨. (PR0이 이미 머지되고 위성이 재발행되어 1.0.0에 대해 설치 가능해야 함.)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Haechi 1.0 Implementation Scope
|
|
2
|
+
|
|
3
|
+
- Status: Draft 0.2 (design — not yet implemented; hardened after a 3-lens adversarial security review, 2026-06-11)
|
|
4
|
+
- Date: 2026-06-11
|
|
5
|
+
- Target version: 1.0.0 (after 0.9.0)
|
|
6
|
+
- Type: stable API contract + plugin sandbox (the first stable release)
|
|
7
|
+
|
|
8
|
+
## 1. Release Goal
|
|
9
|
+
|
|
10
|
+
1.0 is the **first stable release**: it (a) **freezes a stable public API contract** with a deprecation/migration policy and a long-term audit schema, and (b) crosses the line the project has deliberately held since 0.1 — **dynamic loading of external plugin code** — but only through an **asymmetrically-signed, capability-gated, `worker_threads`-isolated, audited** sandbox, and only for the **`authProvider`** contract to start.
|
|
11
|
+
|
|
12
|
+
**Scope decisions (2026-06-11, confirmed with the maintainer):**
|
|
13
|
+
|
|
14
|
+
1. **Sandbox/loading model:** dynamic loading is enabled **only** for plugins that are **signed (Ed25519, asymmetric)**, pass a **capability-manifest allowlist + operator pin/revocation checks**, and run in a **`node:worker_threads` isolation** boundary with full **lifecycle auditing**. `createRuntime(config, providers)` **injection remains the default and recommended path**.
|
|
15
|
+
2. **Plugin scope:** **`authProvider` only** in 1.0. Classifier/filter and crypto plugins stay injection-only until 1.x.
|
|
16
|
+
3. **API freeze:** **strict** — the core public API, the **provider contracts**, the **audit event schema** (including nested sub-schemas), and the **config schema** are frozen under strict semver with a deprecation policy.
|
|
17
|
+
4. **Release shape:** **staged** — 1.0.0 ships the API freeze + the signed-plugin contract/conformance/signing + the worker-isolated `authProvider` sandbox MVP. Stronger capability **enforcement** (child-process + the Node permission model), more plugin kinds, a live revocation feed, and a registry are 1.x.
|
|
18
|
+
|
|
19
|
+
Core stays **zero runtime dependency** — the sandbox is built on `node:worker_threads` + `node:crypto` (Ed25519 sign/verify is a `node:crypto` builtin). It does **not** reuse `packages/policy-bundle` (that is symmetric HMAC — see §2.2).
|
|
20
|
+
|
|
21
|
+
### The honest security model (read this first)
|
|
22
|
+
|
|
23
|
+
**`node:worker_threads` is NOT a security sandbox against malicious code.** A worker shares the process and can still touch the filesystem, the network, and `process.env`; isolation is **V8-heap-only** (Node's permission model is process-wide, not per-worker; `SharedArrayBuffer`/transferables would even reopen a shared-memory channel, so the wire format is a plain JSON string — §2.3). The 1.0 sandbox therefore provides:
|
|
24
|
+
|
|
25
|
+
- **Memory isolation** — separate V8 heap; the plugin cannot read/corrupt host memory, the crypto keys, the token vault, or the audit sink (only the typed message channel crosses).
|
|
26
|
+
- **Crash/hang isolation + resource limits** — `resourceLimits` (heap cap) + a per-call **timeout that terminates the worker** contain a buggy/runaway plugin; a hang fails closed (deny).
|
|
27
|
+
- **Data minimization** — the host sends the worker **only the credential slice** (the `Authorization` header / bearer token), **never the request body** and **never the crypto key**; the worker returns **raw claims**, and the **host** builds the PII-safe identity via `buildExternalIdentity` (the keyed-HMAC key never leaves the host).
|
|
28
|
+
- **A narrow, audited, typed contract** — the worker speaks only the `authProvider` message protocol; every load/deny/terminate decision is audited (§2.4).
|
|
29
|
+
|
|
30
|
+
What the worker boundary does **NOT** give you in 1.0 — these are **accepted residuals, gated only by the signing/vetting trust model**, not by the worker (§6):
|
|
31
|
+
|
|
32
|
+
- **A malicious *signed* plugin can still use the OS** — `fetch`, `fs`, `process.env` are not blocked. `networkEgress: false` in the manifest is a *declaration*, not an enforced control in 1.0.
|
|
33
|
+
- **A malicious *signed* auth plugin can exfiltrate the live credential** it legitimately receives (the bearer token), because it has de-facto network egress. There is **no technical barrier** in 1.0 — only the trust gate.
|
|
34
|
+
|
|
35
|
+
True per-plugin capability **enforcement** (block fs/net, contain the credential) requires **child-process isolation under the Node permission model** (`--permission --allow-fs-read=…`), the documented **1.x** path. This is why injection stays the default and why the trust gate (asymmetric signature + operator allowlist + pin + revocation) is load-bearing.
|
|
36
|
+
|
|
37
|
+
## 2. Scope
|
|
38
|
+
|
|
39
|
+
### 2.1 API stability freeze (the 1.0 contract)
|
|
40
|
+
|
|
41
|
+
**Frozen public surface (an explicit IN/OUT table replaces today's vague "0.x is preview").** Every `package.json` `exports` subpath and the CLI is classed:
|
|
42
|
+
|
|
43
|
+
| Surface | 1.0 status |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `haechi` / `haechi/core` (`createRuntime`, `createHaechi().protectJson`, `collectStringEntries`), `haechi/auth` (`authProvider` contract, `buildExternalIdentity`, `buildIdentity`, `validateLabels`), `haechi/crypto` (`cryptoProvider` contract, `assertCryptoProviderConformance`, `canonicalize`), `haechi/audit` (event schema, `verifyAuditChain`, `sanitizeAudit`, `FORBIDDEN_KEYS`), `haechi/policy`, `haechi/filter` (rule shape), `haechi/token-vault`, `haechi/runtime` (`normalizeConfig` shape), `haechi/protocol-adapters`, `haechi/plugin` (manifest + the new sandbox) | **FROZEN** (breaking change = major) |
|
|
46
|
+
| `haechi/proxy`, `haechi/mcp-stdio`, `haechi/stream-filter`, `haechi/policy-bundle`, `haechi/privacy-profiles`, and the **CLI** (`bin/haechi.mjs`) | **FROZEN BEHAVIOR + wire/contract**; human-readable CLI/log **text** may still change (not part of the contract) |
|
|
47
|
+
| anything still marked experimental in `api-stability.md §3` | must be **graduated** (and removed from §3) or **explicitly kept preview past 1.0** with a stated reason — no silent ambiguity |
|
|
48
|
+
|
|
49
|
+
- **Strict semver from 1.0** (breaking→major, additive→minor, fix→patch). The "0.x minor may break" latitude ends for core.
|
|
50
|
+
- **Deprecation policy.** A deprecated export/field/option is kept **≥1 minor**, emits a documented migration note and a one-time runtime `process.emitWarning` with a **stable `code` prefix `HAECHI_DEPRECATION_*`** (the code/text are themselves part of the contract), and is removed only at the **next major**. **Security exception (the one sanctioned in-minor break):** a change required to close a *disclosed* vulnerability may break/remove within a **minor**, shipped with a security advisory + a migration path (mirroring the existing "blocking unsafe config may tighten in a patch" latitude).
|
|
51
|
+
- **Audit event schema — frozen including nested sub-schemas**, enumerated (not just the top level): top-level `{id, timestamp, protocol, operation, identity, profile, mode, enforced, blocked, payloadShapeHash, detections, summary, auditIntegrity}`; `detections[].{type, ruleId, path, kind, confidence, action, enforced}`; **`identity.{id, type, subjectHash, issuerHash, provider}`** (the PII-safe projection — `scopes`/`labels`/raw subject are **NOT** part of the audit identity); `summary.{byType, byAction, detectionCount}`; `auditIntegrity.{alg, canonicalization, sequence, previousHash, eventHash}`. **New fields are additive-only and never change the canonicalization of existing fields**, so a 1.x event still verifies under a 1.0 `verifyAuditChain` (this holds because `canonicalize` hashes the literal object and the verifier recomputes over the *same* stored object — the guarantee is "a future-additive field doesn't break an old verifier reading a new record", which is sound; the doc states this precisely rather than the earlier hand-wave). A canonicalization change is a **major** event-schema bump with a new `canonicalization` tag + a reader-migration path. An explicit top-level **`schemaVersion`** is added (reader-facing; additive) so consumers branch on it without parsing `auditIntegrity`.
|
|
52
|
+
- **Config schema freeze unit:** config **key presence + shape** is frozen; **default values may still be hardened** (a safer default is not a breaking change). Unknown keys still throw (fail-closed).
|
|
53
|
+
|
|
54
|
+
### 2.1a Satellite compatibility prerequisite (must land BEFORE the core 1.0.0 bump)
|
|
55
|
+
|
|
56
|
+
All four satellites pin `"haechi": ">=0.8.0 <1.0.0"` — and `<1.0.0` **excludes `1.0.0`** (and even `1.0.0-rc.x`). Bumping core to 1.0.0 makes **every satellite's peer dependency unsatisfiable** (ERESOLVE / unmet peer). `haechi-auth-oidc` has the same problem cross-satellite (`"haechi-auth-jwt": ">=0.2.0 <1.0.0"`). So **PR0** (before any core bump):
|
|
57
|
+
|
|
58
|
+
- Widen every satellite peer range to track the core **major**, not the next minor: `"haechi": ">=0.8.0 <2.0.0"` (valid by definition of the freeze — a satellite built against ≥0.8 works through the whole 1.x line), and `haechi-auth-oidc`'s `"haechi-auth-jwt": ">=0.2.0 <2.0.0"`. Patch-release all four (`auth-jwt 0.2.x`, `crypto-kms 0.2.x`, `dashboard 0.1.x`, `auth-oidc 0.1.x`) + regenerate the lockfile (the workspace-lockfile gotcha applies).
|
|
59
|
+
- Add a **`release:preflight` gate** that parses every `satellites/*/package.json` peer range and asserts `semver.satisfies(coreVersion, range)` for the core version about to publish — so a future core major can never ship while a satellite still excludes it.
|
|
60
|
+
- Document in `api-stability.md §5`: the satellite peer **upper bound tracks the core MAJOR**, never pinned below the next minor.
|
|
61
|
+
|
|
62
|
+
### 2.2 Asymmetric signed-plugin contract (Ed25519) + pinning + revocation + conformance
|
|
63
|
+
|
|
64
|
+
**Signing is asymmetric (Ed25519), NOT the symmetric `policy-bundle` HMAC.** `policy-bundle` signs with HMAC keyed off the local AES key file — the verifier holds the same secret that signs, so it cannot express "a third-party author signed; the operator verifies with a public key." 1.0 adds a **`node:crypto` Ed25519** signed-manifest primitive (zero new dependency): the **author holds the Ed25519 private key**; the **operator allowlists the Ed25519 public key** as a trust anchor. (Do not reuse `policy-bundle` for plugin signing.)
|
|
65
|
+
|
|
66
|
+
- **The signed envelope covers content, not a path.** The signed bytes are `canonicalize({ pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter })` — i.e. the signature binds the **sha256 of the exact entry bytes**, the **kind**, the **declared capabilities**, the **compatible core range**, and a **validity window**. Signing a path (or omitting `entrySha256`/`kind`/`capabilities`) is a swap / capability-downgrade attack and is rejected.
|
|
67
|
+
- **Trust-anchor-only key resolution (no kid-by-claim).** The verification key is resolved **only** from the operator's `trustAnchors` allowlist; if `manifest.signerKeyId` is not an allowlisted anchor, **refuse before any verify**. The algorithm is pinned to Ed25519 per anchor (no alg agility, no HS/RS confusion). The plugin trust-anchor set is a **separate curated list**, never the AES rotation key file (retired/rotated AES kids must not become signer anchors).
|
|
68
|
+
- **Pinning (anti malicious-update / rollback).** Operator config `plugin.pin = { version?, entrySha256?, manifestSha256? }`: the loader fails closed if the loaded manifest version / entry hash does not match the pin. A **per-`pluginId` version floor** rejects rollback to an older signed artifact. So a *trusted signer* cannot silently ship a new (or old-vulnerable) entry under the same anchor without tripping the pin/floor.
|
|
69
|
+
- **Revocation + freshness.** Operator denylists `plugin.revokedSignerKeyIds` + `plugin.revokedEntrySha256` checked at load (fail-closed: a revoked signer or hash never loads). The signed `notBefore`/`notAfter` window is enforced at load. **In-memory behavior on revocation** (1.0, stated honestly): revocation takes effect at the **next load/restart**; a **global kill-switch** (`plugins.enabled: false`, and a per-plugin disable) lets an operator **force-drop a live plugin** immediately. A live CRL/feed is 1.x.
|
|
70
|
+
- **Re-verify on every respawn.** Because workers are lazily respawned after a timeout-terminate, the **full gate (signature + anchor + pin + revocation + capability allowlist) re-runs on every spawn**, not only at first construction.
|
|
71
|
+
- **Capability allowlist (operator-side).** `plugins.allowCapabilities`; a manifest requesting a capability outside it → refused. `readsCredentials` is **required** for `kind: authProvider` (it sees the bearer token). `networkEgress`/`readsPlaintext` are **declared and audited but not enforced** by the worker in 1.0 (the §1 residual — surfaced, not trusted).
|
|
72
|
+
- **Conformance is a CORRECTNESS gate, not a malice screen.** `assertAuthProviderConformance(provider, { now, vectors })` runs the **sandboxed** plugin through enumerated security behaviors: missing credential → `null`; malformed credential → `null`; expired / not-yet-valid (clock injected via `now`) → `null`; an internal **throw surfaces as `null`** to the caller (never propagates); a returned identity **MUST** carry `subjectHash`/`issuerHash` and **MUST NOT** contain a field equal to the raw input subject/issuer (PII-safety); deny is **deterministic** for identical input; a valid credential → a well-formed PII-safe identity. The loader **refuses to wire a plugin that fails**. But a signed plugin can detect a fixed test and behave, so: conformance uses **unpredictable per-load randomized vectors**, and — load-bearing — the **host re-validates PII-safety on every call** (`buildExternalIdentity` + the sanitizer below run per request), not just at load. **Conformance-pass does not imply trustworthiness** (that is the signing+vetting gate); test/prod divergence is an accepted residual (§6).
|
|
73
|
+
|
|
74
|
+
### 2.3 The `worker-isolated` `authProvider` sandbox (the MVP)
|
|
75
|
+
|
|
76
|
+
`createSandboxedAuthProvider({ manifestPath, trustAnchors, allowCapabilities, pin, revoked, cryptoProvider, auditSink, timeoutMs, maxPendingCalls, maxMessageBytes, resourceLimits, now })` returns a **host-side `authProvider`** satisfying the frozen contract — so it wires through the **existing** injection seam and the new `auth.provider: "plugin"` config path.
|
|
77
|
+
|
|
78
|
+
- **Load sequence (fail-closed at every step, each step audited):** validate manifest (`worker-isolated` + `kind: authProvider`) → resolve the anchor by `signerKeyId` **from `trustAnchors` only** (else refuse) → read the **entry bytes into memory**, sha256, and **verify the Ed25519 signature over the canonical envelope incl. `entrySha256`** → check `notBefore/notAfter`, the revocation denylists, the pin/version-floor, and capabilities ⊆ allowlist → spawn the Worker **from the in-memory verified source** (`new Worker(code, { eval: true, resourceLimits, workerData: <no secrets> })`), **never re-resolving the path** after verification (no TOCTOU; refuse a symlinked entry) → run `assertAuthProviderConformance` against the sandboxed provider → only then return the live provider. Any failure throws at construction and emits `plugin.load.refused{reason}` (§2.4).
|
|
79
|
+
- **Per-request protocol (data-minimized, correlation-id'd):** `authenticate(request)` extracts **only** the credential slice (the `Authorization` header / token — never the body), wraps it with a **unique correlation id**, and posts it as a **JSON string over the MessagePort** (no structured-clone objects, no `SharedArrayBuffer`/transferables → no shared-memory or object-graph smuggling). `maxMessageBytes` bounds the wire. The worker validates the credential (JWKS egress is inherent to an auth plugin) and returns **raw claims** `{ subject, issuer, type, scopes, labels }` or a deny.
|
|
80
|
+
- **Host-side claims sanitizer (before `buildExternalIdentity`):** the JSON reply is parsed into a **null-prototype object** (`JSON.parse` + reconstruct onto `Object.create(null)`); only a **fixed allowlist of own-enumerable keys** is accepted; `__proto__`/`constructor`/`prototype` are stripped; array sizes and total identity size are bounded; every value is type-validated/coerced at the boundary. Then the **host** builds the PII-safe identity (`buildExternalIdentity({ provider: "plugin:<pluginId>", subject, issuer, type, scopes, labels }, cryptoProvider)`) — the keyed-HMAC key never enters the worker, and a hostile claims object cannot pollute the prototype or smuggle a raw value.
|
|
81
|
+
- **Concurrency model (no cross-caller leakage / no terminate races):** each in-flight call is matched to its reply **by correlation id**; unmatched / duplicate / late replies are **dropped**. The worker is **single-occupancy** (one in-flight call) — a per-call timeout-terminate can therefore never kill a *sibling* call; a pending-call **cap (`maxPendingCalls`)** bounds concurrency (excess → deny). Respawn after terminate is guarded **single-flight**. Plugins are required to be **stateless across calls**; any residual cross-request state risk is a §6 residual.
|
|
82
|
+
- **Timeout + resource bound (fail-closed):** each call is bounded by `timeoutMs` (a **required positive integer — no unbounded default**); on timeout the host **terminates the worker** (`plugin.worker.terminated{cause: timeout}`) and returns `null`, respawning lazily (re-running the full gate). `resourceLimits` caps the heap. (CPU/fd/socket are *not* bounded in 1.0 — §6 residual.)
|
|
83
|
+
- **Config (`auth.provider: "plugin"`) — enumerated fail-closed `normalizeConfig` rules** (matching the keys/tokenVault rigor): require `plugin.manifestPath` (non-empty local path); `plugin.trustAnchors` a non-empty array of `{ keyId: string, publicKey: string (Ed25519) }`; `plugin.allowCapabilities` an array ⊆ `CAPABILITY_KEYS ∪ {readsCredentials}` (reject unknown); `readsCredentials` present for `kind: authProvider`; `plugin.timeoutMs` a positive integer; `resourceLimits.maxOldGenerationSizeMb` a positive integer; optional `plugin.pin`/`plugin.revoked*`/version-floor well-formed; `plugins.enabled` honored (kill-switch). Any violation throws at load. `createRuntime` still requires the injected `cryptoProvider` for the host-side identity build.
|
|
84
|
+
|
|
85
|
+
### 2.4 Audit of the plugin lifecycle (a security product MUST record loading third-party code)
|
|
86
|
+
|
|
87
|
+
Reusing the existing hash-chained `auditSink` (the same seam `recordProxyDecision`/`auth_denied` already uses), the sandbox emits **PII-safe** events — ids/hashes/counts only:
|
|
88
|
+
|
|
89
|
+
- `plugin.load.accepted` `{ pluginId, version, entrySha256, signerKeyId, capabilitiesGranted }`
|
|
90
|
+
- `plugin.load.refused` `{ reason ∈ missing-signature | unknown-signer | tampered-entry | revoked | below-version-floor | pin-mismatch | expired-window | capability-not-allowlisted | conformance-failed | manifest-invalid, pluginId?, signerKeyId? }`
|
|
91
|
+
- `plugin.authenticate.deny` `{ pluginId, reason ∈ deny | invalid-claims | timeout | over-capacity | oversized }`
|
|
92
|
+
- `deny` — plugin returned a plain deny (including a worker-harness-converted internal throw)
|
|
93
|
+
- `invalid-claims` — host-side claims sanitize or `buildExternalIdentity` rejection (subsumes the earlier `non-pii-safe-identity` label)
|
|
94
|
+
- `timeout` — per-call timeout expired; worker terminated and respawned
|
|
95
|
+
- `over-capacity` — `maxPendingCalls` exceeded; call rejected before entering the worker queue
|
|
96
|
+
- `oversized` — credential message exceeds `maxMessageBytes`; not sent to the worker
|
|
97
|
+
- `plugin.worker.terminated` `{ pluginId, cause ∈ timeout | oom | crash }`
|
|
98
|
+
|
|
99
|
+
`FORBIDDEN_KEYS` is **extended** with the plugin/claims surface (`claims`, `subject`, `issuer`, `credential`, `authorization`, `signature`, `entry`) as defense-in-depth so a future plugin event can never leak a raw claim/token/signer secret into the chained log (the events above already carry only ids/hashes). Tests assert a refused load and a worker timeout each emit exactly one chained event, and that a synthetic plugin event with raw claims is stripped by `sanitizeAudit`.
|
|
100
|
+
|
|
101
|
+
### 2.5 Real-environment validation exit criterion
|
|
102
|
+
|
|
103
|
+
- **Met:** the 2026-06-11 live validation against real self-hosted vLLM + Ollama ([[2026-06-11-real-environment-validation]]) + `haechi-dashboard` observability.
|
|
104
|
+
- **Residuals (documented, not gating 1.0):** (1) **live KMS-backend validation** (real AWS/GCP/Azure/Vault) is out-of-CI; (2) **the worker plugin sandbox itself is unproven against a real hostile plugin** — its security rests on the trust gate + the §6 residuals, validated by the fail-closed/data-minimization tests, not by an adversarial third-party-plugin red-team (a 1.x exercise, ideally alongside the child-process+permission enforcement).
|
|
105
|
+
|
|
106
|
+
## 3. Explicit non-scope (deferred to 1.x)
|
|
107
|
+
|
|
108
|
+
- **Capability *enforcement*** against a malicious signed plugin (block fs/net, contain the credential) — needs child-process isolation under the Node permission model.
|
|
109
|
+
- **Classifier/filter and crypto plugin loading** — `authProvider` only in 1.0.
|
|
110
|
+
- **A live revocation feed / CRL**, a plugin **registry / marketplace**, multi-origin, hot-reload, and an **unsigned dev loader** (which would undermine the trust gate — development uses injection).
|
|
111
|
+
- **Python SDK.**
|
|
112
|
+
|
|
113
|
+
## 4. Backward compatibility & the 1.0 stability contract
|
|
114
|
+
|
|
115
|
+
Existing behavior is **unchanged** — every provider contract, the config and (now nested-enumerated) audit schemas, and the zero-dependency posture are exactly as in 0.9; they are **declared frozen**. The plugin sandbox is **purely additive and opt-in** (`auth.provider: "plugin"`; default stays `none`/`bearer`/`external`). The one behavioral core change is the **additive `FORBIDDEN_KEYS` extension** (§2.4) and the **`schemaVersion`** field (additive). The **satellite peer-range widening (§2.1a) is a prerequisite** so the four satellites keep installing against core 1.0.0.
|
|
116
|
+
|
|
117
|
+
## 5. 1.0 relationship / what 1.0 closes
|
|
118
|
+
|
|
119
|
+
1.0 closes the two long-standing 1.0 gates — **API-stability freeze** (§2.1) and the **plugin sandbox + dynamic-loading story** (§2.2–2.4: asymmetric-signed + isolated + audited + auth-only) — and records the **real-environment-validation** exit criterion as met with documented residuals (§2.5). It graduates Haechi from developer preview to a stable self-hosted security gateway while keeping the core promise: a small zero-dependency core, fail-closed everywhere, and "the same security tests pass when you swap a component."
|
|
120
|
+
|
|
121
|
+
## 6. Threat-model & risk-register deltas (concrete)
|
|
122
|
+
|
|
123
|
+
| New surface (1.0) | Control | Residual |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| **Malicious/compromised signed plugin** loaded dynamically | Ed25519 signature over `entrySha256`+kind+capabilities, trust-anchor-only key resolution, pin + version-floor + revocation denylist, conformance gate, worker memory/crash isolation, full lifecycle audit | **A signed plugin's own fs/net/`process.env` is NOT blocked, and it CAN exfiltrate the credential it receives** — gated only by the signing/vetting trust model; true enforcement is the 1.x child-process+permission path |
|
|
126
|
+
| **PII/secret leak to a plugin** | Only the credential slice crosses (never the body/keys); JSON-string wire; null-proto sanitizer; host builds the keyed-HMAC identity | the credential the auth plugin legitimately validates is visible to it (see row above) |
|
|
127
|
+
| **Cross-boundary object/proto smuggling** | JSON-string wire (no structured clone / SAB / transferables) + null-proto allowlist sanitizer before `buildExternalIdentity` | none material |
|
|
128
|
+
| **Swap / TOCTOU on the entry** | Sign `entrySha256`; read-into-memory + hash + verify + spawn from in-memory source; no path re-resolution; reject symlinks | none material |
|
|
129
|
+
| **Signer-key confusion / downgrade / rollback / malicious update** | Trust-anchor-only resolution, pinned algorithm, pin/version-floor, revocation | operator must curate anchors/pins |
|
|
130
|
+
| **Plugin DoS** | Per-call `timeoutMs` terminate, heap `resourceLimits`, `maxPendingCalls`, `maxMessageBytes`, single-occupancy worker | a signed plugin can burn its allotted CPU within the timeout (CPU/fd not bounded in 1.0) |
|
|
131
|
+
| **Unaudited code-load** | `plugin.load.*` / `authenticate.deny` / `worker.terminated` audit events; extended `FORBIDDEN_KEYS` | — |
|
|
132
|
+
| **Conformance test/prod divergence** | Randomized per-load vectors + per-call host re-validation of PII-safety | a malicious plugin can pass conformance then misbehave (covered by signing+vetting, not conformance) |
|
|
133
|
+
| **API/audit-schema drift** | Strict semver + deprecation windows (+ security exception) + additive-only nested-enumerated audit schema + `schemaVersion` | a major bump can break by design (documented migration) |
|
|
134
|
+
|
|
135
|
+
Proposed risk IDs: **P1-SEC-024** (dynamic plugin execution / sandbox trust model — supersedes P1-SEC-004's manifest-only stance, lifted under the new controls), **P1-SEC-025** (plugin signing/trust-anchor/revocation lifecycle), **P2-API-001** (stable-contract freeze + deprecation policy), **P2-OPS-006** (satellite peer-range / major-tracking gate). New §4 exclusions: capability enforcement vs a malicious signed plugin, credential containment, classifier/crypto plugin loading, unsigned dev loader, live CRL.
|
|
136
|
+
|
|
137
|
+
## 7. Test criteria (mapped to the PR breakdown)
|
|
138
|
+
|
|
139
|
+
### 7.1 PR0 — satellite peer-range widening + the preflight gate
|
|
140
|
+
- All four satellites' `haechi` peer range widened to `>=0.8.0 <2.0.0` (and auth-oidc's `haechi-auth-jwt` to `<2.0.0`); lockfile regenerated; `release:preflight` fails if `!semver.satisfies(coreVersionToPublish, satelliteRange)` for any satellite. A test simulates core `1.0.0` and asserts every satellite range is satisfied.
|
|
141
|
+
|
|
142
|
+
### 7.2 PR1 — API stability freeze (docs + contract test)
|
|
143
|
+
- `api-stability.md`(+ko) carries the IN/OUT table, strict-semver + deprecation policy (incl. the `HAECHI_DEPRECATION_*` runtime-warning contract and the security exception), and the satellite major-tracking rule.
|
|
144
|
+
- A **contract/snapshot test** pins the frozen exports per subpath + a **full audit event including a non-null `identity` and one `detections[]` entry** (so the nested sub-schemas are guarded, not just the top level) + the config-schema key set + `schemaVersion`. An additive field passes; a removed/renamed field (top-level OR nested) fails. `verifyAuditChain` verifies a frozen-schema fixture and still verifies it with a synthetic additive field.
|
|
145
|
+
|
|
146
|
+
### 7.3 PR2 — Ed25519 signed-plugin contract + pinning/revocation + conformance harness
|
|
147
|
+
- `packages/plugin` accepts a `worker-isolated`+`authProvider` manifest with the Ed25519 envelope; **refuses** (distinct fail-closed tests, each emitting `plugin.load.refused{reason}`): missing/invalid signature; signer not in `trustAnchors` (kid-not-allowlisted, resolved **before** verify); **entry bytes mutated after signing, path unchanged**; revoked signer / revoked entryHash; below version-floor; pin mismatch; outside `notBefore/notAfter`; capability not allowlisted; alg ≠ Ed25519.
|
|
148
|
+
- `assertAuthProviderConformance` exists; a reference provider passes; a broken one (throws / returns a raw-subject identity / accepts an expired credential / non-deterministic) **fails** each case (negative tests). Vectors are randomized per run.
|
|
149
|
+
- `FORBIDDEN_KEYS` extension test: a synthetic plugin event with `claims`/`credential`/`signature` is stripped by `sanitizeAudit`; the chain stays valid.
|
|
150
|
+
|
|
151
|
+
### 7.4 PR3 — the `worker-isolated` authProvider sandbox
|
|
152
|
+
- A reference **signed** auth plugin loads, passes conformance in the worker, authenticates a valid bearer/JWT into a **host-built PII-safe identity**; assertions: the worker received **only** the credential slice (an instrumented echo-plugin proves it never got the body / audit sink / token vault / key), the raw subject never appears in the audit, `plugin.load.accepted` is emitted with the resolved `entrySha256`/`signerKeyId`.
|
|
153
|
+
- **Fail-closed + isolation matrix:** unsigned/wrong-signer/tampered/revoked/pin-mismatch/capability-not-allowlisted → construction throws + `load.refused`; **timeout → `null` + worker terminated + `worker.terminated{timeout}`**; throw → `null`; a claims object with `__proto__`/extra keys → sanitized (no prototype pollution, extras dropped) and PII-safe; two concurrent calls with distinct correlation ids never cross responses; a terminate of one call cannot kill a sibling (single-occupancy); `maxPendingCalls`/`maxMessageBytes` enforced; `plugins.enabled:false` (kill-switch) refuses load.
|
|
154
|
+
- `normalizeConfig` `auth.provider:"plugin"` enumerated fail-closed tests (each bad option throws); end-to-end through `createRuntime` + the proxy auth gate (a request authenticates via the plugin; identity keyed-HMAC; audit carries no raw subject/credential).
|
|
155
|
+
|
|
156
|
+
### 7.5 All
|
|
157
|
+
- Core stays zero runtime dependency (`node:` only — Ed25519 is `node:crypto`); `check:packaging` + `check:satellite-packaging` green; the frozen-contract snapshot test + the peer-range preflight gate guard future PRs.
|
|
158
|
+
|
|
159
|
+
## 8. Suggested PR breakdown (stacked)
|
|
160
|
+
1. **PR0 — satellite peer-range widening + preflight gate** (prerequisite; patch-release the four satellites). → §7.1
|
|
161
|
+
2. **API freeze** — `api-stability.md`(+ko) IN/OUT table + deprecation/security-exception policy + the nested-schema contract/snapshot test + `schemaVersion`. → §7.2
|
|
162
|
+
3. **Ed25519 signed-plugin contract + conformance** — the asymmetric primitive (`node:crypto`), the signed envelope (entryHash/kind/capabilities/window), trust-anchor-only resolution, pin/version-floor/revocation, `assertAuthProviderConformance`, the `FORBIDDEN_KEYS` extension. → §7.3
|
|
163
|
+
4. **Worker-isolated authProvider sandbox** — `createSandboxedAuthProvider` (in-memory verified spawn, JSON-string wire, null-proto sanitizer, correlation-id single-occupancy concurrency, timeout/terminate, kill-switch), the `auth.provider:"plugin"` config branch + lifecycle audit, a reference signed plugin + the §7.4 matrix. → §7.4
|
|
164
|
+
5. **1.0.0 release cut** — bump core to **1.0.0**; docs EN/KO (this scope doc, threat-model + risk-register deltas with the §6 IDs + target bump, the real-env exit criterion + residuals); wiki ingest (a `[[plugin-sandbox]]` page + `[[packaging-and-distribution]]`/`[[identity-and-auth]]`/`release-roadmap` updates); README "Current Scope". Core reuses the `v*` tag; the first stable `haechi@1.0.0` publishes attested. (PR0 must already be merged + the satellites republished so they install against 1.0.0.)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Haechi 1.1 구현 범위
|
|
2
|
+
|
|
3
|
+
- 상태: **구현 + 출시 완료** (2026-06-12; PR #54/#55/#56 + 이 릴리스 컷, core 1.0.0 → 1.1.0). 설계는 Node 26 실측을 동반한 3-렌즈 적대적 검토 후 강화됨.
|
|
4
|
+
- 구현 노트(설계 원문 대비 차이):
|
|
5
|
+
- **fail-closed `--allow-net` 기능 탐지**(`netEnforcementSupported` + 기본값 `netEnforcement: "require-permission"`)는 PR3가 아니라 **PR1**에서 출시됨 — 런타임 안전에 본질적이기 때문: Node 22(--allow-net 없음) CI가 이것 없이는 런타임이 net 미봉쇄로 동작함을 입증했고, 이는 이 설계가 거부하는 "봉쇄하는 척" 실패다. 탐지는 동작을 probe한다(`--permission` 자식에서 `net.connect`가 거부되어야 함) — 플래그가 나열됐지만 강제되지 않는 Node에 면역.
|
|
6
|
+
- 승격된 SSRF 가드의 **satellite 재import**(§2.3)는 **연기됨**: `haechi-auth-jwt`/`haechi-auth-oidc`/`haechi-crypto-kms`가 `haechi/ssrf`를 import하게 하면 그들의 `haechi` peer floor가 1.1로 올라가고, 의도적 "교차 패키지 SSRF 결합 금지" 결정(`crypto-kms/ssrf-parity.test.mjs`)을 뒤집는다. 대신 코어 복사본은 코어-대-`auth-jwt` parity 테스트로 정직하게 유지된다; drift는 제거가 아니라 가드됨.
|
|
7
|
+
- 리스크 ID는 **P1-SEC-026/027 → P1-SEC-027/028**로 재번호됨(제안된 P1-SEC-026이 기존 0.9 OIDC 브로커 리스크와 충돌).
|
|
8
|
+
- 날짜: 2026-06-11
|
|
9
|
+
- 대상 버전: 1.1.0 (1.0.0 이후)
|
|
10
|
+
- 유형: 플러그인 샌드박스의 capability **강제(enforcement)** (1.0의 정직한 잔여 위험을 닫음)
|
|
11
|
+
|
|
12
|
+
## 1. 릴리스 목표
|
|
13
|
+
|
|
14
|
+
1.1은 1.0 플러그인 샌드박스의 대표적인 **정직한 잔여 위험(honest residual)** 을 닫는다. 1.0은 `node:worker_threads`가 **메모리/크래시 격리일 뿐 capability 샌드박스가 아니다** 라고 명시했다 — 악성 *서명된* 플러그인이 여전히 `fs`/`net`을 사용해 전달받은 자격증명을 유출할 수 있었다. 1.1은 **더 강한 opt-in `process-isolated` 런타임**을 추가한다. 이 런타임은 서명된 `authProvider` 플러그인을 **Node 권한 모델(`--permission`) 하의 자식 프로세스**에서 실행하며, **`--allow-net`을 fail-closed로 요구하는 네트워크 봉쇄**, **모든 stdio 무시**(stdout/stderr/fd 유출 채널 없음), 그리고 플러그인을 **파일시스템 권한이 전혀 없는 `data:` URL에서 로드**한다 — 따라서 악성 서명 플러그인은 **호스트 파일시스템을 읽을 수도, 프로세스를 spawn할 수도, 네트워크에 도달할 수도, 호스트가 볼 수 있는 어떤 sink에 쓸 수도 없고**, 그러므로 **자격증명을 유출할 수 없다**.
|
|
15
|
+
|
|
16
|
+
Draft 0.1에 대한 적대적 검토(Node 26 실측)가 이 설계를 재구성했고, 그 교정 사항은 아래에 반영되어 있다:
|
|
17
|
+
|
|
18
|
+
- **"`node:net`/fetch를 삭제하는" 하니스는 봉쇄가 아니다.** `process.binding('tcp_wrap')`은 살아있는 소켓을 열고, `import('node:net')`은 캐시 삭제와 무관하게 새 builtin을 재해석한다. 따라서 네트워크 봉쇄는 JS 하니스가 아니라 **커널이 강제하는 `--allow-net` 거부**여야 한다. `--allow-net`이 없는 Node(Node 22 LTS에는 없음)에서는 `process-isolated`가 봉쇄하는 척하지 않고 **fail-closed로 동작**한다.
|
|
19
|
+
- **자식 프로세스는 `--allow-net`이 막지 못하는 stdout/stderr/상속 fd 쓰기 채널을 추가한다.** 이들을 명시적으로 닫지 않으면(stdio 무시 + 전용 IPC 채널) 자격증명이 로그로 유출된다.
|
|
20
|
+
- **임시 디렉터리에 `--allow-fs-read`를 주는 것은 TOCTOU + macOS realpath/symlink 실패 + 숨은 번들링 요구를 부른다.** 검증된 바이트를 **`data:` URL**(1.0 worker가 이미 쓰는 방식)에서 로드하면 **fs 권한이 전혀 필요 없고**, TOCTOU/symlink 표면 전체가 사라지며, 자족적 단일 파일 플러그인을 구조적으로 강제한다.
|
|
21
|
+
|
|
22
|
+
**범위 결정(2026-06-11, 메인테이너 확정; 아래 network/mode/credential/scope 선택은 검토로 다듬은 네 가지 권장 답변이다):**
|
|
23
|
+
|
|
24
|
+
1. **격리:** `process-isolated` = `--permission` 하의 자식 `node` 프로세스로, **기본적으로 아무것도 부여하지 않으며**(fs 없음, child-process 없음, worker 없음, addons 없음, wasi 없음), 플러그인을 `data:` URL에서 로드하고, `stdio: ['ignore','ignore','ignore','ipc']` + 정화된 `env`로 spawn한다.
|
|
25
|
+
2. **네트워크 = fail-closed `--allow-net`.** 네트워크 봉쇄는 권한 모델의 `--allow-net` 거부이며 **기능 탐지 + fail-closed**이다: 실행 중인 Node가 `--allow-net` 강제를 입증하지 못하면 `process-isolated`는 **생성을 거부**한다(기본값 `netEnforcement: "require-permission"`). 비-봉쇄 best-effort 대체는 오직 명시적 `allow-harness` opt-in 뒤에서, **악성 플러그인을 봉쇄하지 못한다**는 요란한 경고와 함께만 존재한다.
|
|
26
|
+
3. **자격증명 처리:** **표준 JWT/JWKS** 자격증명은 **호스트**가 감사되는 `createJwtVerifier`(satellite 경로 재사용)를 실행하므로 플러그인이 **필요 없다**; `process-isolated` 플러그인은 플러그인이 직접 파싱해야 하는 **커스텀/불투명 자격증명**용이며, 거기서 플러그인은 원본 자격증명을 보지만 **net + stdio + fs 거부**로 봉쇄된다(유출 불가). 커스텀 플러그인이 필요로 하는 키 자료는 **호스트가 fetch해 주입**한다(플러그인이 URL을 고르지 않음 → 플러그인 주도 SSRF 없음).
|
|
27
|
+
4. **모드 + 범위:** `process-isolated`는 변경되지 않은 1.0 `worker-isolated`와 **나란히 존재하는 새롭고 더 강한 opt-in** 런타임이다; 1.1은 이 capability 강제 런타임에 **집중**한다. Classifier/crypto 플러그인, 라이브 CRL, 레지스트리는 이후 마이너에 남긴다.
|
|
28
|
+
|
|
29
|
+
코어는 **런타임 의존성 0**(`node:child_process` + `--permission` + `node:crypto`/`node:dns`)을 유지한다. 1.1은 additive + opt-in이며, 새 모듈 외 유일한 코어 변경은 **SSRF `isBlockedAddress` 가드를 코어의 node:-only 헬퍼로 승격**하는 것이다(§2.3) — 호스트 중개 fetch가 그것을 쓸 수 있도록(코어는 satellite를 import할 수 없음).
|
|
30
|
+
|
|
31
|
+
## 2. 범위
|
|
32
|
+
|
|
33
|
+
### 2.1 `process-isolated` authProvider 런타임 (커널 강제 capability, fs 없음, stdio 없음)
|
|
34
|
+
|
|
35
|
+
새 매니페스트 `runtime: "process-isolated"`(`kind: "authProvider"`용, `worker-isolated`와 나란히). `createProcessIsolatedAuthProvider(options)`는 `authenticate()`를 자식 `node` 프로세스로 프록시하는 호스트측 `authProvider`(frozen 계약)를 반환한다.
|
|
36
|
+
|
|
37
|
+
- **로드 게이트 우선(PR2 게이트, fail-closed, 감사됨):** 엔트리 바이트를 **메모리에서** 두고 `verifySignedPlugin`(`entrySha256` + kind/capabilities/window에 대한 Ed25519, trust-anchor 전용 해석, pin/version-floor/revocation).
|
|
38
|
+
- **`data:` URL로 로드 — fs 권한 없음, TOCTOU 없음.** 자식은 검증된 바이트를 `data:text/javascript;base64,…` URL로 import한다(1.0 worker가 이미 쓰는 메커니즘). 자식은 **`--allow-fs-read`가 전혀 없이** spawn된다 → 호스트 파일시스템을 읽을 수 없다. 이로써 temp-dir / realpath / symlink / TOCTOU 표면이 통째로 사라지고, **자족적 단일 파일 플러그인**(런타임 `import`/`require`로 호스트 파일을 끌어오지 않음)을 구조적으로 요구한다; 로드 게이트는 소스가 정적으로 비-`data:` specifier를 참조하는 엔트리를 추가로 거부한다.
|
|
39
|
+
- **허용 목록 capability만 부여하는 `--permission` spawn:** `process.execPath` + `--permission`, **`--allow-fs-read`/`--allow-fs-write`/`--allow-child-process`/`--allow-worker`/`--allow-addons`/`--allow-wasi` 없음**. `env`는 최소 고정 집합으로 **정화**된다(상속된 호스트 비밀 없음 — `--permission`은 상속 env를 보호하지 않음; env 정화가 한다). `--disable-proto=delete`.
|
|
40
|
+
- **stdio 완전 차단(검토가 드러낸 새롭고 핵심적인 통제):** `stdio: ['ignore','ignore','ignore','ipc']` — **stdout 없음, stderr 없음, 추가 상속 fd 없음**; 유일한 채널은 전용 IPC다. 호스트는 자식 stdout/stderr를 **결코** 전달/로깅/감사하지 않는다(자격증명을 stderr에 쓰는 플러그인은 그렇지 않으면 운영자 로그로 유출된다). `sendHandle`/fd 전달 없음.
|
|
41
|
+
- **JSON-문자열 전용 IPC(structured clone 없음, fd 전달 없음).** `child_process` IPC는 advanced(structured-clone) 직렬화 + 핸들 전달을 지원하는데, 이는 1.0 sanitizer가 막으려 했던 object/proto/transferable 밀반입을 다시 연다. 런타임은 IPC로 **JSON 문자열만** 송수신하며(`serialization: "json"`), correlation-id + null-proto 허용 목록 sanitizer + 호스트측 `buildExternalIdentity`는 1.0 worker 경로와 정확히 동일하다.
|
|
42
|
+
- **단일 점유 + fail-closed 매트릭스**(timeout → kill, `maxPendingCalls`, `maxMessageBytes`, kill-switch)는 §2.4의 프로세스 수명주기 추가와 함께 그대로 이어진다.
|
|
43
|
+
- **로드 시 적합성**은 샌드박스 자식에 대해 `assertAuthProviderConformance`(무작위 벡터)를 실행한다.
|
|
44
|
+
|
|
45
|
+
### 2.2 네트워크 봉쇄 = fail-closed `--allow-net` (하니스는 봉쇄가 아니다)
|
|
46
|
+
|
|
47
|
+
- **`--allow-net`만이 진짜 네트워크 통제다.** 네트워크가 필요 없는 `process-isolated` 플러그인은 자식이 **`--allow-net` 없이** spawn된다; 이를 강제하는 Node에서 `net.connect`/`fetch`/`dns` → `ERR_ACCESS_DENIED`(커널 강제). 이것이 실제로 자격증명 유출을 막는다.
|
|
48
|
+
- **기능 탐지 + fail-closed, 버전 파싱 없음.** 생성 시 런타임은 `process.allowedNodeEnvironmentFlags.has('--allow-net')`로 `--allow-net` 지원을 탐지하고, **spawn-probe로 한 번 확인**(`node --permission --allow-net -e 0` → exit 0 = 지원, exit 9 = 미지원)한 뒤 런타임 수명 동안 캐시한다. 기본값 **`netEnforcement: "require-permission"`**: 지원이 입증되지 않으면 `createRuntime`/`normalizeConfig`가 **throw**(시작 거부)하며 조용히 격하되지 않는다. 따라서 자격증명 봉쇄 보장은 `--allow-net` Node(그것을 탑재한 버전 이상)를 요구한다; 그것이 없는 Node 22 LTS → fail closed.
|
|
49
|
+
- **하니스는 best-effort 전용이며 그렇게 라벨된다.** *순진한/우발적* egress를 위한 이식성 있는 `allow-harness` opt-in이 존재할 수 있지만, 설계는 문서·감사(`netEnforcement: "harness"` + 시작 **경고**)·위협 모델에서 평이하게 명시한다 — **악성 서명 플러그인을 봉쇄하지 못한다**(`process.binding('tcp_wrap')`와 새 `import('node:net')`이 모두 네트워크에 도달). 추가로 `process.binding`/`internalBinding`을 stub해야 하지만, 그래도 견고하지 않다. 고확신 운영자는 `require-permission`(기본값)을 쓴다.
|
|
50
|
+
|
|
51
|
+
### 2.3 자격증명 처리 — 호스트측 JWT, 호스트 중개 키 자료, 코어의 SSRF 가드
|
|
52
|
+
|
|
53
|
+
- **표준 JWT/JWKS: 호스트가 검증하고, 어떤 플러그인도 원본 자격증명을 보지 않는다.** 흔한 JWT의 경우 **호스트**가 감사되는 `createJwtVerifier`(satellite 경로)를 실행하므로 `process-isolated` 플러그인은 **불필요**하다 — 호스트 검증기를 직접 쓴다(`auth.provider: "external"`/satellite). 1.1은 원본 JWT를 자식으로 라우팅하지 않는다.
|
|
54
|
+
- **커스텀/불투명 자격증명: 플러그인이 원본을 보지만 egress 거부로 봉쇄된다.** `process-isolated` 플러그인은 플러그인이 파싱해야 하는 비표준 자격증명을 위해 존재한다. 검증을 위해 IPC로 원본 자격증명을 받지만(받아야만 한다), **net + stdio + fs가 모두 거부**되므로 그것을 **유출할 수 없다**. 플러그인은 원본 claims를 반환하고, 호스트가 정화 + keyed-HMAC 신원을 구축한다(crypto 키는 결코 넘어가지 않음).
|
|
55
|
+
- **호스트 중개 키 자료(플러그인 주도 SSRF 없음).** 커스텀 플러그인이 필요로 하는 키 자료(예: JWKS 유사 문서)는 **운영자가 선언한** URL에서 — 플러그인이 고른 URL이 아니라 — **호스트**가 **SSRF 강화된 가드 fetch**로 가져와 IPC로 주입한다. kid 기반 재fetch는 **rate-limit/cooldown으로 제한**(bearer satellite가 이미 하듯)되어 공격자의 자격증명이 호스트의 아웃바운드 요청을 펌프질할 수 없다.
|
|
56
|
+
- **SSRF 가드가 코어로 이동한다.** `isBlockedAddress` + 가드 fetch 패턴(DNS 후 재확인, HTTPS 전용, 본문 제한, fetch timeout, `redirect:"error"`)은 현재 `haechi-auth-jwt` satellite에만 있고 코어는 그것을 import할 수 없다. 1.1은 **node:-only `isBlockedAddress`/`guardedFetch`를 코어 모듈로 승격**(코어는 의존성 0 유지)하며, satellite들(`auth-jwt`, `auth-oidc`, `crypto-kms`의 Vault 복사본)과 호스트 fetch가 그 하나의 코어 헬퍼를 import해 drift를 끝낸다. 알려진 DNS-rebinding 창(resolve-then-connect)은 잔여로 문서화하며, 운영자 선언 host-JWKS의 경우 single-origin/issuer 결합은 완화한다.
|
|
57
|
+
|
|
58
|
+
### 2.4 프로세스 수명주기(anti-DoS) — 서킷 브레이커 + 워밍된 자식
|
|
59
|
+
|
|
60
|
+
호출마다 새 `node --permission` spawn은 수십 ms이며, timeout이 나는 플러그인은 모든 인증 시도를 콜드 spawn으로 바꿔 증폭 DoS를 만들 수 있다. 그래서:
|
|
61
|
+
|
|
62
|
+
- 호출 전반에 재사용되는 **워밍된 장수 자식**(단일 점유 직렬화 유지), 한 번 spawn해 준비 상태로 유지.
|
|
63
|
+
- timeout/크래시 시 재spawn은 **서킷 브레이커**로 통제된다: T초 내 N회 kill이면 **영구 fail-closed deny로 trip**(`plugin.worker.terminated{cause:"respawn-storm"}`, 운영자 reset 필요)하고 재spawn 사이에 **지수 백오프**를 둔다 — 플래핑하는 플러그인이 spawn 폭풍이 될 수 없다.
|
|
64
|
+
- `maxPendingCalls`/`maxMessageBytes`와 kill-switch(`plugins.enabled:false`)가 적용된다.
|
|
65
|
+
|
|
66
|
+
### 2.5 설정 + 감사(호스트 계산 필드만)
|
|
67
|
+
|
|
68
|
+
- `auth.provider:"plugin"`에 `plugin.isolation: "worker" | "process"`와 `plugin.netEnforcement: "require-permission" | "allow-harness"`(기본 `"require-permission"`)가 추가된다. `normalizeConfig`는 fail-closed로 검증한다: `process`는 `process-isolated` 매니페스트 + capability 허용 목록을 요구하고; `--allow-net` 없는 Node에서의 `require-permission`은 **throw**하며; 호스트 fetch URL(커스텀 플러그인이 키 자료를 필요로 할 때)은 운영자 선언이어야 한다. `worker`-vs-`process` 기본값은 1.0 하위호환을 위해 `worker`로 남지만 **문서는 새 고확신 운영자를 `process` + `require-permission`으로 안내**하며, 선택된 모드는 감사에 기록된다.
|
|
69
|
+
- **감사 필드는 호스트 계산/enum 전용(결코 자식 공급 아님).** 수명주기 이벤트에 additive `isolation`, `grants`(**호스트가 계산한** 부여 권한 집합, 플러그인 입력의 에코가 아님), `netEnforcement`가 추가된다 — 모두 고정 enum/호스트 값. 자식 크래시/권한 거부 진단은 `error.message`/자식 출력이 아니라 **고정 reason enum**(`PLUGIN_LOAD_REASONS` 확장)으로 매핑된다(코어 감사 sanitizer는 값이 아니라 키 *이름*으로 거른다 — 자유 텍스트 필드는 자격증명을 해시 체인에 쓸 수 있으므로 모든 새 필드는 허용 목록/enum). 이들은 `plugin.*` 수명주기 이벤트에 있고 frozen 코어 protect-event 스키마 **밖**이므로 1.0 `api-contract.test.mjs` freeze 가드는 영향받지 않는다(이 문서가 *이유*를 명시해 미래 메인테이너가 수명주기 이벤트를 잘못 freeze하지 않도록 한다).
|
|
70
|
+
|
|
71
|
+
### 2.6 정직한 모델 — 1.1이 닫는 것과 닫지 않는 것
|
|
72
|
+
|
|
73
|
+
**`--allow-net` Node에서 `process-isolated` + `require-permission`** 의 경우, 악성 서명 플러그인은 봉쇄된다:
|
|
74
|
+
|
|
75
|
+
- **fs / exec / worker / addons:** 커널 **강제** 거부(`--permission`, 부여 없음); 플러그인은 fs가 전혀 없는 `data:` URL에서 로드된다.
|
|
76
|
+
- **network:** 커널 **강제** 거부(`--allow-net` 부재) → **네트워크 통한 자격증명 유출 없음**.
|
|
77
|
+
- **stdio / fd:** **차단**(`ignore` + 전용 IPC, 상속 fd 없음) → 로그/stderr 유출 없음.
|
|
78
|
+
- **env 비밀:** 정화됨.
|
|
79
|
+
|
|
80
|
+
**잔여 표면(이 이상으로 과신 금지):** (a) **`--allow-net`이 없는** Node는 **네트워크 봉쇄가 없다** — 운영자가 비-봉쇄 `allow-harness`를 명시 수용하지 않는 한 `process-isolated`는 거기서 fail closed; (b) 정당하게 **`networkEgress:true`** 가 필요한 플러그인은 봉쇄되지 않음; (c) 호스트 fetch SSRF 가드에 **DNS-rebinding** 창이 있음; (d) **자격증명 + 주입된 키 자료가 자식 메모리에** 존재 — core-dump/swap 노출은 범위 밖; (e) `--permission`은 OS 샌드박스가 아니라 Node 런타임 통제 — Node/V8 탈출은 이를 무력화한다. `worker-isolated`(1.0) 모드는 **불변** — 그 trust-only 잔여는 그대로다.
|
|
81
|
+
|
|
82
|
+
## 3. 명시적 비범위(이후 마이너)
|
|
83
|
+
- Classifier/filter 및 crypto 플러그인 로딩(authProvider 전용).
|
|
84
|
+
- 라이브 revocation 피드 / CRL; 플러그인 레지스트리.
|
|
85
|
+
- `allow-harness` 대체를 실제 봉쇄로 강화(불가능 — `--allow-net` 없는 Node에서는; 답은 `require-permission`).
|
|
86
|
+
- Node 권한 모델을 넘는 OS 수준 샌드박싱(seccomp/namespaces/sandbox-exec).
|
|
87
|
+
- `worker-isolated` 대체.
|
|
88
|
+
|
|
89
|
+
## 4. 하위 호환성
|
|
90
|
+
Additive + opt-in. `worker-isolated`, injection, 모든 provider 계약, frozen 1.0 API/audit/config 스키마는 불변. `process-isolated`는 새 매니페스트 런타임 + 새 `plugin.isolation`/`plugin.netEnforcement` 설정(기본값은 1.0 동작 보존). `plugin.*` 수명주기 감사 이벤트는 additive 호스트 계산 필드를 얻는다(frozen protect-event 스키마 밖 — 계약 테스트는 영향 없음). `isBlockedAddress`를 코어 node:-only 모듈로 승격하는 것은 additive(satellite들이 재import; 코어는 런타임 의존성 0 유지). 엄격한 1.0 semver상 1.1은 **마이너**.
|
|
91
|
+
|
|
92
|
+
## 5. 1.1 관계
|
|
93
|
+
1.1은 플러그인 샌드박스를 **신뢰 기반**(1.0 worker: 서명자를 신뢰)에서 **capability 강제**(1.1 process: OS/런타임이 서명된 코드를 제한)로, 새 opt-in 모드에 대해 강화하여 가장 많이 인용된 1.0 잔여를 *정직하게* 닫는다 — 첫 초안이 틀렸던 부분(하니스는 봉쇄가 아니다; stdio는 유출 채널이다; fail-closed 기능 탐지) 포함. 의존성 0, fail-closed 코어 약속을 유지한다.
|
|
94
|
+
|
|
95
|
+
## 6. 위협 모델 & 리스크 레지스터 델타
|
|
96
|
+
|
|
97
|
+
| 표면(1.1) | 통제 | 잔여 |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| 악성 서명 플러그인의 호스트 fs/exec/worker/addons 남용 | `--permission` 자식, **부여 0**, `data:`-URL 로드(fs 없음) | `--permission` Node에서는 없음; V8/Node 탈출은 모든 런타임 통제를 무력화 |
|
|
100
|
+
| 네트워크 통한 자격증명 유출 | `--allow-net` **거부**, **fail-closed 기능 탐지**(`require-permission` → 미지원 시 throw) | `--allow-net` 없는 Node → fail closed(또는 명시적 비-봉쇄 `allow-harness`); `networkEgress:true` 플러그인 |
|
|
101
|
+
| **stdout/stderr/fd** 통한 자격증명 유출 | `stdio:['ignore','ignore','ignore','ipc']`, 상속 fd 없음, 호스트가 자식 출력을 로깅 안 함 | 실질적으로 없음 |
|
|
102
|
+
| `child_process` IPC 통한 object/proto/fd 밀반입 | JSON-문자열 전용 IPC(`serialization:"json"`), null-proto 허용 목록 sanitizer | 실질적으로 없음 |
|
|
103
|
+
| 플러그인 주도 SSRF / 아웃바운드 펌프 | 호스트가 가져오는 **운영자 선언** URL만(코어 SSRF 가드), kid-refetch cooldown | 가드의 DNS-rebinding 창 |
|
|
104
|
+
| 새 필드 통한 감사 평문 유출 | 호스트 계산/enum 전용 필드, 고정 reason enum, 자식 자유 텍스트 없음 | 실질적으로 없음 |
|
|
105
|
+
| Spawn-storm DoS | 워밍 자식 + 서킷 브레이커 + 백오프 | trip된 브레이커는 운영자 reset까지 거부(fail-closed) |
|
|
106
|
+
|
|
107
|
+
리스크 ID(최종): **P1-SEC-027**(process-isolated capability **강제** — P1-SEC-024의 worker 잔여를 강화: fs/exec/net/stdio가 이제 강제됨), **P1-SEC-028**(호스트 중개 키 자료 + 코어 SSRF 가드). *(제안된 026/027에서 재번호 — P1-SEC-026은 기존 0.9 OIDC 브로커 리스크.)* 1.0 P1-SEC-024 행에 "`--allow-net` Node의 `process-isolated`에 대해 1.1에서 강제됨" 주석. 새 §4 제외: `--allow-net` 없는 Node에서의 네트워크 봉쇄(fail-closed), `networkEgress:true` 플러그인, core-dump/swap, OS 수준 탈출.
|
|
108
|
+
|
|
109
|
+
## 7. 테스트 기준(PR 분해에 매핑)
|
|
110
|
+
|
|
111
|
+
### 7.1 PR1 — `process-isolated` 런타임(capability + stdio + data-URL + fail-closed net)
|
|
112
|
+
- `process-isolated` 모드의 계측된 서명 플러그인은 `fs.readFileSync('/etc/hosts')`가 **거부**되고(`ERR_ACCESS_DENIED`), 자식/worker를 spawn할 수 없으며, **fs 권한이 없다**(`data:` URL에서 로드).
|
|
113
|
+
- **Net 레드팀:** `--allow-net` Node에서 플러그인의 `net.connect` / `fetch` / `dns`와 `process.binding('tcp_wrap')` 소켓이 모두 **실패**(커널 거부); `--allow-net`이 **없는** Node에서 `require-permission`인 `createRuntime`는 **생성 시 throw**(fail-closed) — 조용한 하니스 격하가 아님.
|
|
114
|
+
- **stdio/fd 레드팀:** 자격증명을 `stdout`/`stderr`/`console.error`/fd3에 쓰는 플러그인은 **호스트가 볼 수 있는 어떤 sink에도 도달하지 못함**(stdio 무시; 호스트는 아무것도 캡처 안 함).
|
|
115
|
+
- IPC는 JSON-문자열 전용(핸들/structured-clone 객체 전달 시도는 거부); 로드 게이트 + 적합성 + fail-closed 매트릭스(timeout→kill, sanitizer, 단일 점유, kill-switch)가 프로세스 모드에서 유지; macOS 포함 크로스 플랫폼 실행.
|
|
116
|
+
|
|
117
|
+
### 7.2 PR2 — 자격증명 봉쇄 + 호스트 중개 키 자료 + 코어 SSRF 가드
|
|
118
|
+
- 커스텀 자격증명 플러그인이 원본 자격증명으로 인증하되, net+stdio+fs 거부 하에서 계측된 유출 시도(네트워크 AND stderr AND fd)가 **어떤 sink에도 도달하지 못함**(자격증명이 결코 떠나지 않음을 단언).
|
|
119
|
+
- 호스트 중개 fetch가 **승격된 코어** `isBlockedAddress`를 사용(사설/메타데이터 범위로 resolve되는 `jwksUri`는 거부; 플러그인은 URL을 명명하지 않음); kid-refetch cooldown이 아웃바운드 비율을 제한; satellite들은 코어 가드를 import하며 자신의 스위트를 여전히 통과.
|
|
120
|
+
|
|
121
|
+
### 7.3 PR3 — 기능 탐지 + 수명주기 + 감사 + 1.1.0 릴리스 컷
|
|
122
|
+
- `process.allowedNodeEnvironmentFlags` + spawn-probe를 통한 `--allow-net` 탐지가 dev Node에서 정확하고 미지원 시 fail-closed; `netEnforcement` 감사됨; spawn 서킷 브레이커가 respawn 폭풍에 trip(감사함); `normalizeConfig` `plugin.isolation`/`netEnforcement` fail-closed 테스트.
|
|
123
|
+
- 수명주기 감사 additive 필드가 호스트 계산/enum 전용(플러그인이 값을 밀반입할 수 없음); 1.0 `api-contract.test.mjs`가 여전히 통과(additive, frozen protect-event 스키마 밖). 위협 모델/리스크 레지스터 델타(P1-SEC-026/027), wiki, README; 코어를 **1.1.0**으로 bump; 검증 게시.
|
|
124
|
+
|
|
125
|
+
## 8. 제안 PR 분해(스택)
|
|
126
|
+
1. **`process-isolated` 런타임** — `createProcessIsolatedAuthProvider`: `data:`-URL 로드(fs 없음), `--permission` 부여-0 spawn, `stdio:['ignore','ignore','ignore','ipc']` + 정화 env, JSON-문자열 IPC, 데이터 최소화 wire + 호스트 신원, fail-closed + stdio/net 레드팀 테스트. → §7.1
|
|
127
|
+
2. **자격증명 봉쇄 + 코어 SSRF 가드** — `isBlockedAddress`/`guardedFetch`를 코어 node:-only 모듈로 승격(satellite들이 재import); 호스트 중개 운영자 선언 키 fetch + IPC 주입 + kid cooldown; exfil-blocked + no-SSRF 테스트. → §7.2
|
|
128
|
+
3. **기능 탐지 + 수명주기 + 감사 + 1.1.0 컷** — `--allow-net` 탐지 + `netEnforcement`(fail-closed `require-permission` 기본값), 워밍 자식 + 서킷 브레이커, 호스트 계산 감사 필드; `plugin.isolation`/`netEnforcement` 설정; 문서 EN/KO(이 문서, 위협 모델 + 리스크 레지스터 P1-SEC-026/027, 정직한 모델 갱신), wiki, README; 코어 → 1.1.0, 검증 게시. → §7.3
|