haechi 1.0.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 CHANGED
@@ -4,7 +4,7 @@
4
4
  [![CI](https://github.com/raeseoklee/haechi/actions/workflows/ci.yml/badge.svg)](https://github.com/raeseoklee/haechi/actions/workflows/ci.yml)
5
5
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
6
6
  [![node](https://img.shields.io/node/v/haechi)](https://nodejs.org)
7
- [![status](https://img.shields.io/badge/status-stable%201.0-brightgreen)](docs/current/api-stability.md)
7
+ [![status](https://img.shields.io/badge/status-stable%201.1-brightgreen)](docs/current/api-stability.md)
8
8
 
9
9
  [English](README.md) | **한국어**
10
10
 
@@ -52,7 +52,7 @@ npm run demo:report
52
52
 
53
53
  기본 설정은 `dry-run` 모드로 실행된다. 민감한 값을 탐지하고 audit 메타데이터를 기록하지만, 정책 모드를 변경하기 전까지는 아웃바운드 payload를 수정하지 않는다.
54
54
 
55
- `npm run demo:init`은 `haechi.config.json`과 `.haechi/dev.keys.json`을 로컬에 생성한다. 생성된 키 파일은 로컬 개발 전용이다. Haechi 0.3.x는 운영 환경용 KMS/HSM/Vault 키 provider를 포함하지 않는다. 비밀 정보를 포함하지 않는 템플릿은 `haechi.config.example.json`에서 확인할 수 있다.
55
+ `npm run demo:init`은 `haechi.config.json`과 `.haechi/dev.keys.json`을 로컬에 생성한다. 생성된 키 파일은 로컬 개발 전용이다. 코어는 운영 환경용 KMS/HSM/Vault 키 provider를 포함하지 않으며, KMS·Vault 기반 키 custody는 `haechi-crypto-kms` satellite로 제공된다(외부 `cryptoProvider` 계약을 통해 주입). 비밀 정보를 포함하지 않는 템플릿은 `haechi.config.example.json`에서 확인할 수 있다.
56
56
 
57
57
  ## Local Proxy
58
58
 
@@ -72,7 +72,7 @@ upstream 요청은 `limits.upstreamTimeoutMs`(기본값 120000) 이후 타임아
72
72
 
73
73
  ## Local Inference Servers
74
74
 
75
- Haechi 0.3은 OpenAI 호환 서버, vLLM, Ollama, llama.cpp를 위한 프로토콜 adapter 프리셋을 포함한다.
75
+ Haechi OpenAI 호환 서버, vLLM, Ollama, llama.cpp를 위한 프로토콜 adapter 프리셋을 포함한다.
76
76
 
77
77
  ```json
78
78
  {
@@ -255,7 +255,7 @@ Haechi는 로컬 정책 부트스트래핑을 위한 기본 지역별 Privacy Pr
255
255
  - Audit tail truncation: `audit.anchor.mode: file`을 설정하면(추가 전용/별도 미디어에서) `haechi audit-verify --anchor`가 마지막 anchor 이후 꼬리 레코드 삭제를 탐지한다. 동일한 쓰기 가능 파일시스템에서는 공격자가 두 파일을 함께 잘라낼 수 있다.
256
256
  - Key custody: `keys.provider: external`은 주입된 `cryptoProvider`를 허용한다; `assertCryptoProviderConformance`로 adapter를 검증한다. envelope 암호화 KMS adapter는 `haechi-crypto-kms` satellite(`satellites/crypto-kms/`)가 제공한다.
257
257
  - Release integrity: 배포된 tarball에는 npm provenance attestation이 포함되며, GitHub release asset에는 sigstore attestation과 `SHA256SUMS`가 추가된다(`gh attestation verify`와 `node scripts/release-checksums.mjs --check`로 검증한다).
258
- - 1.0 authProvider 플러그인 샌드박스는 서명된 플러그인을 `worker_threads` worker에서 실행한다. 이는 메모리/크래시 격리와 데이터 최소화(credential 슬라이스만 넘어가고, host가 keyed-HMAC identity를 만든다)이며 capability 샌드박스가 **아니다**: 악의적인 *서명된* 플러그인은 여전히 `fs`/`net`을 사용해 받은 credential을 유출할 수 있다. load-bearing 통제는 trust gate(Ed25519 서명 + 운영자 allowlist + 버전 pin/floor + revocation)다. 기본 배선은 dependency injection(`createRuntime(config, providers)`)으로 유지되며, 진정한 capability 강제(child-process + Node permission model) 1.x 목표다.
258
+ - 1.0 authProvider 플러그인 샌드박스는 서명된 플러그인을 `worker_threads` worker에서 실행한다. 이는 메모리/크래시 격리와 데이터 최소화(credential 슬라이스만 넘어가고, host가 keyed-HMAC identity를 만든다)이며 capability 샌드박스가 **아니다**: 악의적인 *서명된* 플러그인은 여전히 `fs`/`net`을 사용해 받은 credential을 유출할 수 있다. load-bearing 통제는 trust gate(Ed25519 서명 + 운영자 allowlist + 버전 pin/floor + revocation)다. **1.1이 잔존 위험을 닫는다** — opt-in `process-isolated` 런타임(`auth.plugin.isolation: "process"`): 서명된 플러그인을 Node 권한 모델(`--permission`, 부여 0) 하의 자식 프로세스에서 실행하며, fs/net/exec/worker가 커널 거부되고, 모든 stdio가 무시되며, `data:` URL로 로드(fs 권한 없음)된다 진정한 capability 강제. `--allow-net`을 강제하는 Node 필요하며 그렇지 않으면 **fail closed**한다; 변경되지 않은 `worker_threads` 모드가 기본으로 유지된다. 기본 배선은 dependency injection(`createRuntime(config, providers)`)으로 유지된다.
259
259
  - 자체 네트워크 통제와 인증을 앞에 두지 않고 Haechi를 인터넷에 노출된 운영 LLM 게이트웨이로 사용하지 않는다.
260
260
 
261
261
  ## 현재 범위
@@ -283,3 +283,5 @@ Haechi는 로컬 정책 부트스트래핑을 위한 기본 지역별 Privacy Pr
283
283
  0.9.0은 관측성(observability) + 대화형 인증 테마이다: 두 개의 새 위성 — [`haechi-dashboard`](satellites/dashboard/)(audit 로그와 hash chain 상태에 대한 zero-dependency 읽기 전용 `node:http` audit 뷰어; anti-DNS-rebinding Host allowlist, 엄격한 CSP/Trusted Types, fail-closed loopback/remote-bind 가드 포함)와 [`haechi-auth-oidc`](satellites/auth-oidc/)(대시보드의 사람 로그인을 제공하는 대화형 OIDC 세션 브로커 — authorization-code + PKCE + 서버측 세션). 기존 위성도 additive minor를 발행한다: `haechi-auth-jwt@0.2.0`은 재사용 가능한 JWS 검증기(`createJwtVerifier`)를 export하고, `haechi-crypto-kms@0.2.0`은 GCP/Azure/Vault 백엔드를 추가한다. core는 `0.9.0`으로 bump되며, 추가적인 `FORBIDDEN_KEYS` audit 새니타이즈 강화(현재 이벤트 출력은 바뀌지 않는 심층 방어)만 포함한다. `docs/current/release-0.9-implementation-scope.md` 참고.
284
284
 
285
285
  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` 참고.
286
+
287
+ 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` 참고.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![CI](https://github.com/raeseoklee/haechi/actions/workflows/ci.yml/badge.svg)](https://github.com/raeseoklee/haechi/actions/workflows/ci.yml)
5
5
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
6
6
  [![node](https://img.shields.io/node/v/haechi)](https://nodejs.org)
7
- [![status](https://img.shields.io/badge/status-stable%201.0-brightgreen)](docs/current/api-stability.md)
7
+ [![status](https://img.shields.io/badge/status-stable%201.1-brightgreen)](docs/current/api-stability.md)
8
8
 
9
9
  **English** | [한국어](README.ko.md)
10
10
 
@@ -52,7 +52,7 @@ npm run demo:report
52
52
 
53
53
  The default config runs in `dry-run` mode. It detects sensitive values and writes audit metadata, but it does not modify outbound payloads until policy mode is changed.
54
54
 
55
- `npm run demo:init` writes `haechi.config.json` and `.haechi/dev.keys.json` locally. The generated key file is for local development only. Haechi 0.3.x does not include a production KMS/HSM/Vault key provider. A non-secret template is available at `haechi.config.example.json`.
55
+ `npm run demo:init` writes `haechi.config.json` and `.haechi/dev.keys.json` locally. The generated key file is for local development only. Core ships no production KMS/HSM/Vault key provider; KMS- and Vault-backed key custody is available through the `haechi-crypto-kms` satellite, injected via the external `cryptoProvider` contract. A non-secret template is available at `haechi.config.example.json`.
56
56
 
57
57
  ## Local Proxy
58
58
 
@@ -72,7 +72,7 @@ Upstream requests time out after `limits.upstreamTimeoutMs` (default 120000) and
72
72
 
73
73
  ## Local Inference Servers
74
74
 
75
- Haechi 0.3 includes protocol adapter presets for OpenAI-compatible servers, vLLM, Ollama, and llama.cpp.
75
+ Haechi includes protocol adapter presets for OpenAI-compatible servers, vLLM, Ollama, and llama.cpp.
76
76
 
77
77
  ```json
78
78
  {
@@ -255,7 +255,7 @@ Set `privacy.profile` in `haechi.config.json` to apply the profile's default act
255
255
  - Audit tail truncation: set `audit.anchor.mode: file` (on append-only/separate media) so `haechi audit-verify --anchor` detects deletion of trailing records back to the last anchor. On the same writable filesystem an attacker can truncate both files together.
256
256
  - Key custody: `keys.provider: external` accepts an injected `cryptoProvider`; validate adapters with `assertCryptoProviderConformance`. The `haechi-crypto-kms` satellite (`satellites/crypto-kms/`) provides an envelope-encryption KMS adapter.
257
257
  - Release integrity: published tarballs carry an npm provenance attestation; GitHub release assets add a sigstore attestation and `SHA256SUMS` (verify with `gh attestation verify` and `node scripts/release-checksums.mjs --check`).
258
- - The 1.0 authProvider plugin sandbox runs a signed plugin in a `worker_threads` worker. This is memory/crash isolation and data-minimization (only the credential slice crosses; the host builds the keyed-HMAC identity), **not** a capability sandbox: a malicious *signed* plugin can still use `fs`/`net` and exfiltrate the credential it receives. The load-bearing control is the trust gate (Ed25519 signature + operator allowlist + version pin/floor + revocation). Default wiring stays dependency injection (`createRuntime(config, providers)`); true capability enforcement (child-process + Node permission model) is a 1.x target.
258
+ - The 1.0 authProvider plugin sandbox runs a signed plugin in a `worker_threads` worker. This is memory/crash isolation and data-minimization (only the credential slice crosses; the host builds the keyed-HMAC identity), **not** a capability sandbox: a malicious *signed* plugin can still use `fs`/`net` and exfiltrate the credential it receives. The load-bearing control is the trust gate (Ed25519 signature + operator allowlist + version pin/floor + revocation). **1.1 closes this residual** with an opt-in `process-isolated` runtime (`auth.plugin.isolation: "process"`): the signed plugin runs in a child process under the Node permission model (`--permission`, zero grants) with kernel-denied fs/net/exec/worker, all stdio ignored, and a `data:`-URL load (no fs grant) — real capability enforcement. It requires a Node that enforces `--allow-net` and **fails closed** otherwise; the unchanged `worker_threads` mode stays the default. Default wiring stays dependency injection (`createRuntime(config, providers)`).
259
259
  - Do not expose Haechi as an internet-facing production LLM gateway without your own network controls and authentication in front.
260
260
 
261
261
  ## Current Scope
@@ -283,3 +283,5 @@ Set `privacy.profile` in `haechi.config.json` to apply the profile's default act
283
283
  0.9.0 is the observability + interactive-auth theme: two new satellites — [`haechi-dashboard`](satellites/dashboard/) (a zero-dependency, read-only `node:http` audit viewer over the audit log and its hash-chain status, with an anti-DNS-rebinding Host allowlist, strict CSP/Trusted Types, and fail-closed loopback/remote-bind guards) and [`haechi-auth-oidc`](satellites/auth-oidc/) (an interactive OIDC session broker — authorization-code + PKCE + server-side sessions — that provides the dashboard's human login). Existing satellites also ship additive minors: `haechi-auth-jwt@0.2.0` exports a reusable JWS verifier (`createJwtVerifier`) and `haechi-crypto-kms@0.2.0` adds GCP/Azure/Vault backends. Core bumps to `0.9.0`, carrying only an additive `FORBIDDEN_KEYS` audit-sanitization hardening — defense-in-depth that changes no current event output. See `docs/current/release-0.9-implementation-scope.md`.
284
284
 
285
285
  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`.
286
+
287
+ 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`.
package/SECURITY.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Scope
4
4
 
5
- This repository is an experimental self-hosted security toolkit. It is not production-ready and is not a compliance certification, legal opinion, or assurance report.
5
+ This repository is a self-hosted security toolkit. It is not a compliance certification, legal opinion, or assurance report.
6
6
 
7
7
  Release risk tracking is maintained in `docs/current/risk-register-release-gate.md`. npm release checks must pass `npm run release:preflight`; actual npm publication additionally requires `npm run release:preflight:npm` from an authenticated npm account.
8
8
 
package/docs/README.md CHANGED
@@ -20,7 +20,7 @@ English is the primary documentation language. Korean translations are maintaine
20
20
  - `docs/current/release-0.7-implementation-scope.md`: 0.7 audit anchoring, cryptoProvider contract + reference KMS adapter, signed release artifacts
21
21
  - `docs/current/configuration.md`: full configuration reference (every key, defaults, validation, presets, common setups)
22
22
  - `docs/current/risk-register-release-gate.md`: release-blocking risks, security/operational risk status, npm release gates (0.3.2 baseline)
23
- - `docs/current/threat-model.md`: Haechi 0.3.2 trust boundaries, protected assets, key threats and controls
23
+ - `docs/current/threat-model.md`: Haechi trust boundaries, protected assets, key threats and controls
24
24
  - `docs/current/shared-responsibility.md`: responsibility split between Haechi and users/operators in self-hosted deployments
25
25
  - `docs/current/api-stability.md`: developer preview API stability and migration note criteria
26
26
  - `docs/current/release-process.md`: release preflight, SBOM, npm provenance publish procedure
@@ -141,10 +141,29 @@ upstream JSON 응답을 검사한다(기본적으로 꺼져 있음 — 모델로
141
141
 
142
142
  | 키 | 타입 / 값 | 기본값 | 설명 |
143
143
  |---|---|---|---|
144
- | `auth.provider` | `none` \| `bearer` \| `external` | `none` | `none` = 인증 없음(identity null). `bearer` = 내장 token auth. `external`은 `createRuntime(config, { authProvider })`를 통해 `authProvider`를 주입해야 한다. |
144
+ | `auth.provider` | `none` \| `bearer` \| `external` \| `plugin` | `none` | `none` = 인증 없음(identity null). `bearer` = 내장 token auth. `external`은 `createRuntime(config, { authProvider })`를 통해 `authProvider`를 주입해야 한다. `plugin` = 서명된 `authProvider` 샌드박스([`auth.plugin`](#authplugin-signed-authprovider-sandbox) 참고). |
145
145
  | `auth.store` | 경로 | `.haechi/auth.json` | Bearer token 저장소(모드 `0600`). Token은 keyed-HMAC 해시로만 보관되며, 평문은 `haechi auth add` 실행 시 한 번만 표시된다. |
146
146
  | `auth.allowedLabelKeys` | 문자열 배열 | `["team", "env", "tier", "role"]` | Token이 가질 수 있는 label 키; 값은 길이가 제한되며 PII를 포함하면 안 된다. |
147
147
 
148
+ ### `auth.plugin` (signed authProvider sandbox)
149
+
150
+ `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) 참고.
151
+
152
+ | Key | Type / values | Default | Notes |
153
+ |---|---|---|---|
154
+ | `auth.plugin.manifestPath` | 경로 | — | 서명된 플러그인 매니페스트(`haechi.plugin.json`). |
155
+ | `auth.plugin.trustAnchors` | `[{keyId, publicKey}]` 또는 `{ keyId: publicKey }` | — | 운영자 allowlist된 Ed25519 **공개** 키. 키 해석은 trust-anchor 전용. |
156
+ | `auth.plugin.allowCapabilities` | 문자열 배열 | — | capability 허용 목록; `readsCredentials` 포함 필수. 목록에 없는 요청 capability → 로드 거부. |
157
+ | `auth.plugin.isolation` | `worker` \| `process` | `worker` | `worker` = `worker_threads`(memory/crash 격리, **1.0**). `process` = Node 권한 모델 하 자식으로 **커널 강제** capability 거부(**1.1**); `--allow-net`을 강제하는 Node 필요. |
158
+ | `auth.plugin.timeoutMs` | 양의 정수 | — | call별 timeout; timeout 시 런타임이 자식/worker를 terminate하고 deny. |
159
+ | `auth.plugin.resourceLimits` | `{ maxOldGenerationSizeMb }` | — | **`worker` 전용** — `worker_threads` heap bound. `process`에는 N/A. |
160
+ | `auth.plugin.netEnforcement` | `require-permission` | `require-permission` | **`process` 전용** — 네트워크 봉쇄 정책. `require-permission`은 `--allow-net` 없는 Node에서 **fail closed**(생성 거부). |
161
+ | `auth.plugin.keyMaterial` | `{ url (https), ttlMs?, cooldownMs? }` | unset | **`process` 전용** — **호스트**가 가져와(SSRF 가드 + TTL+cooldown) 커스텀 자격증명 플러그인에 주입하는 선택적 운영자 선언 키 문서. 플러그인은 URL을 명명하지 않음. |
162
+ | `auth.plugin.pin` | `{ version?, entrySha256?, manifestSha256? }` | unset | 정확 일치 pin(악성 업데이트/rollback 방지). |
163
+ | `auth.plugin.revoked` | `{ signerKeyIds?, entrySha256? }` | unset | revocation denylist(로드 시 fail-closed). |
164
+ | `auth.plugin.versionFloor` | `{ <pluginId>: version }` | unset | 플러그인별 최소 버전(rollback 방지). |
165
+ | `auth.plugin.maxPendingCalls` / `maxMessageBytes` | 양의 정수 | `8` / `16384` | 동시성 + wire 한계(초과/oversized → deny). |
166
+
148
167
  ## `policy` profiles & limits
149
168
 
150
169
  기본 `policy` 위에 클라이언트별 통제를 레이어로 추가한다. [Named profiles](#named-profiles) 참고.
@@ -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,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
@@ -0,0 +1,128 @@
1
+ # Haechi 1.1 Implementation Scope
2
+
3
+ - Status: **Implemented + shipped** (2026-06-12; PRs #54/#55/#56 + this release cut, core 1.0.0 → 1.1.0). Design hardened after a 3-lens adversarial review with empirical Node-26 testing.
4
+ - Implementation notes (deltas from this design as written):
5
+ - The **fail-closed `--allow-net` feature detection** (`netEnforcementSupported` + `netEnforcement: "require-permission"` default) shipped in **PR1**, not PR3 — it is intrinsic to the runtime's safety: CI on Node 22 (no `--allow-net`) proved that without it the runtime would run net-uncontained, the exact "pretend to contain" failure this design rejects. Detection probes BEHAVIOR (a `--permission` child must see `net.connect` denied), immune to a flag-listed-but-unenforced Node.
6
+ - The **satellite re-import** of the promoted SSRF guard (§2.3) was **deferred**: forcing `haechi-auth-jwt`/`haechi-auth-oidc`/`haechi-crypto-kms` to import `haechi/ssrf` would raise their `haechi` peer floor to 1.1 and reverse their deliberate "no cross-package SSRF coupling" decision (`crypto-kms/ssrf-parity.test.mjs`). The core copy is kept honest by a core-vs-`auth-jwt` parity test instead; the drift is guarded, not yet eliminated.
7
+ - Risk IDs renumbered **P1-SEC-026/027 → P1-SEC-027/028** (the proposed P1-SEC-026 collided with the existing 0.9 OIDC-broker risk).
8
+ - Date: 2026-06-11
9
+ - Target version: 1.1.0 (after 1.0.0)
10
+ - Type: capability **enforcement** for the plugin sandbox (closes the 1.0 honest residual)
11
+
12
+ ## 1. Release Goal
13
+
14
+ 1.1 closes the headline **honest residual** of the 1.0 plugin sandbox: 1.0 was explicit that `node:worker_threads` is **memory/crash isolation only, not a capability sandbox** — a malicious *signed* plugin could still use `fs`/`net` and exfiltrate the credential it receives. 1.1 adds a **stronger, opt-in `process-isolated` runtime** that runs a signed `authProvider` plugin in a **child process under the Node permission model (`--permission`)**, with **network containment that fail-closed requires `--allow-net`**, **all stdio ignored** (no stdout/stderr/fd leak channel), and the plugin loaded **from a `data:` URL with no filesystem grant at all** — so a malicious signed plugin **cannot read the host filesystem, spawn, reach the network, or write to any host-visible sink**, and therefore **cannot exfiltrate the credential**.
15
+
16
+ The adversarial review of Draft 0.1 (empirically, on Node 26) reshaped this design — those corrections are baked in below:
17
+
18
+ - **A "delete `node:net`/fetch" harness is NOT containment.** `process.binding('tcp_wrap')` opens a live socket and `import('node:net')` re-resolves a fresh builtin regardless of cache deletion. So network containment **must** be the kernel-enforced `--allow-net` denial, not a JS harness. On a Node without `--allow-net` (Node 22 LTS has none), `process-isolated` **fails closed** rather than pretending to contain.
19
+ - **A child process adds stdout/stderr/inherited-fd write channels** that `--allow-net` does not gate. These are closed explicitly (stdio ignored + a dedicated IPC channel), or the credential leaks via a log.
20
+ - **`--allow-fs-read` on a temp dir invites TOCTOU + a macOS realpath/symlink failure + a hidden bundling requirement.** Loading the verified bytes from a **`data:` URL** (as the 1.0 worker already does) needs **zero fs grant**, removes the whole TOCTOU/symlink surface, and structurally enforces a self-contained single-file plugin.
21
+
22
+ **Scope decisions (2026-06-11, maintainer-confirmed; the network/mode/credential/scope choices below are the four recommended answers, refined by the review):**
23
+
24
+ 1. **Isolation:** `process-isolated` = a child `node` process under `--permission`, granting **nothing by default** (no fs, no child-process, no worker, no addons, no wasi), loading the plugin from a `data:` URL, with `stdio: ['ignore','ignore','ignore','ipc']` and a scrubbed `env`.
25
+ 2. **Network = fail-closed `--allow-net`.** Network containment is the permission model's `--allow-net` denial, **feature-detected and fail-closed**: if the running Node cannot prove `--allow-net` enforcement, `process-isolated` **refuses to construct** (the default `netEnforcement: "require-permission"`). A best-effort non-containing fallback exists only behind an explicit `allow-harness` opt-in with a loud warning that **it does not contain a malicious plugin**.
26
+ 3. **Credential handling:** for a **standard JWT/JWKS** credential the **host** runs the audited `createJwtVerifier` (reusing the satellite path) and the plugin is **not needed**; the `process-isolated` plugin is for **custom/opaque credentials** a plugin must parse — there the plugin sees the raw credential but is contained by **net + stdio + fs denial** (it cannot exfiltrate). Any key material a custom plugin needs is **host-fetched and injected** (no plugin-chosen URLs → no plugin-driven SSRF).
27
+ 4. **Mode + scope:** `process-isolated` is a **new, stronger, opt-in** runtime *alongside* the unchanged 1.0 `worker-isolated`; 1.1 is **focused** on this capability-enforcement runtime. Classifier/crypto plugins, a live CRL, and a registry stay in later minors.
28
+
29
+ Core stays **zero runtime dependency** (`node:child_process` + `--permission` + `node:crypto`/`node:dns`). 1.1 is additive and opt-in; the only core change beyond the new module is **promoting the SSRF `isBlockedAddress` guard into a core node:-only helper** (§2.3) so the host-mediated fetch can use it (core cannot import from a satellite).
30
+
31
+ ## 2. Scope
32
+
33
+ ### 2.1 The `process-isolated` authProvider runtime (kernel-enforced capabilities, no fs, no stdio)
34
+
35
+ A new manifest `runtime: "process-isolated"` (for `kind: "authProvider"`, alongside `worker-isolated`). `createProcessIsolatedAuthProvider(options)` returns a host-side `authProvider` (frozen contract) that proxies `authenticate()` into a child `node` process.
36
+
37
+ - **Load gate first (the PR2 gate, fail-closed, audited):** `verifySignedPlugin` (Ed25519 over `entrySha256` + kind/capabilities/window, trust-anchor-only resolution, pin/version-floor/revocation) over the entry bytes held **in memory**.
38
+ - **Load via `data:` URL — no fs grant, no TOCTOU.** The child imports the verified bytes as a `data:text/javascript;base64,…` URL (the mechanism the 1.0 worker already uses). The child is spawned with **no `--allow-fs-read`** at all → it cannot read the host filesystem. This removes the temp-dir / realpath / symlink / TOCTOU surface entirely, and structurally requires a **self-contained single-file plugin** (no runtime `import`/`require` of host files); the load gate additionally rejects an entry whose source statically references a non-`data:` specifier.
39
+ - **Spawn under `--permission` granting only the allowlisted capabilities:** `process.execPath` + `--permission` with **no** `--allow-fs-read`/`--allow-fs-write`/`--allow-child-process`/`--allow-worker`/`--allow-addons`/`--allow-wasi`. `env` is **scrubbed** to a minimal fixed set (no inherited host secrets — `--permission` does not protect inherited env; env-scrubbing does). `--disable-proto=delete`.
40
+ - **stdio fully closed (a new, load-bearing control the review surfaced):** `stdio: ['ignore','ignore','ignore','ipc']` — **no stdout, no stderr, no extra inheritable fd**; the only channel is the dedicated IPC. The host **never** forwards, logs, or audits child stdout/stderr (a plugin writing the credential to stderr would otherwise leak it into operator logs). No `sendHandle`/fd passing.
41
+ - **JSON-string-only IPC (no structured clone, no fd passing).** `child_process` IPC supports advanced (structured-clone) serialization + handle passing, which would reopen the object/proto/transferable smuggling the 1.0 sanitizer was built to stop. The runtime sends/receives **only JSON strings** over the IPC (`serialization: "json"`), with the correlation-id + null-proto allowlist sanitizer + host-side `buildExternalIdentity` exactly as the 1.0 worker path.
42
+ - **Single-occupancy + the fail-closed matrix** (timeout → kill, `maxPendingCalls`, `maxMessageBytes`, kill-switch) carry over, with the process-lifecycle additions in §2.4.
43
+ - **Conformance at load** runs `assertAuthProviderConformance` against the sandboxed child (randomized vectors).
44
+
45
+ ### 2.2 Network containment = fail-closed `--allow-net` (the harness is not containment)
46
+
47
+ - **`--allow-net` is the only real network control.** For a `process-isolated` plugin that does not need network, the child is spawned **without** `--allow-net`; on a Node that enforces it, `net.connect`/`fetch`/`dns` → `ERR_ACCESS_DENIED` (kernel-enforced). This is what actually prevents credential exfiltration.
48
+ - **Feature-detected, fail-closed, no version parsing.** At construction the runtime detects `--allow-net` support via `process.allowedNodeEnvironmentFlags.has('--allow-net')`, **confirmed once by a spawn-probe** (`node --permission --allow-net -e 0` → exit 0 = supported, exit 9 = not), cached for the runtime lifetime. The default **`netEnforcement: "require-permission"`**: if support is not proven, `createRuntime`/`normalizeConfig` **throws** (refuses to start) rather than silently degrading. So the credential-containment guarantee requires a `--allow-net` Node (Node ≥ the version that ships it); Node 22 LTS without it → fail closed.
49
+ - **The harness is best-effort-only and labeled as such.** A portable `allow-harness` opt-in may exist for *naive/accidental* egress, but the design states plainly — in the doc, the audit (`netEnforcement: "harness"` + a startup **warning**), and the threat model — that **it does NOT contain a malicious signed plugin** (`process.binding('tcp_wrap')` and a fresh `import('node:net')` both reach the network). It must additionally stub `process.binding`/`internalBinding`, but even then is not robust. High-assurance operators use `require-permission` (the default).
50
+
51
+ ### 2.3 Credential handling — host-side JWT, host-mediated key material, the SSRF guard in core
52
+
53
+ - **Standard JWT/JWKS: the host verifies; no plugin sees the raw credential.** For the common JWT case, the **host** runs the audited `createJwtVerifier` (the satellite path) and a `process-isolated` plugin is **redundant** — use the host verifier directly (`auth.provider: "external"`/the satellite). 1.1 does not route a raw JWT through a child.
54
+ - **Custom/opaque credentials: the plugin sees the raw credential, contained by egress denial.** The `process-isolated` plugin exists for non-standard credentials a plugin must parse. It receives the raw credential over the IPC (it must, to validate it), but with **net + stdio + fs all denied** it **cannot exfiltrate** it. It returns raw claims; the host sanitizes + builds the keyed-HMAC identity (the crypto key never crosses).
55
+ - **Host-mediated key material (no plugin-driven SSRF).** Any key material a custom plugin needs (e.g. a JWKS-like document) is fetched by the **host** from an **operator-declared** URL — never a plugin-chosen one — through an **SSRF-hardened guarded fetch**, and injected over the IPC. The kid-driven refetch is **rate-limited/cooldown-bounded** (as the bearer satellite already does) so an attacker's credential cannot pump the host's outbound requests.
56
+ - **The SSRF guard moves into core.** `isBlockedAddress` + the guarded-fetch pattern (post-DNS re-check, HTTPS-only, bounded body, fetch timeout, `redirect:"error"`) live today only in the `haechi-auth-jwt` satellite, which core cannot import. 1.1 **promotes a node:-only `isBlockedAddress`/`guardedFetch` into a core module** (core stays zero-dependency); the satellites (`auth-jwt`, `auth-oidc`, and the `crypto-kms` Vault copy) and the host-fetch all import the one core helper, ending the drift. The known DNS-rebinding window (resolve-then-connect) is documented as a residual; the single-origin/issuer coupling is relaxed for the operator-declared host-JWKS case.
57
+
58
+ ### 2.4 Process lifecycle (anti-DoS) — circuit breaker + warm child
59
+
60
+ A fresh `node --permission` spawn per call is ~tens of ms; a timing-out plugin could turn every auth attempt into a cold spawn (amplification DoS). So:
61
+
62
+ - A **warmed, long-lived child** reused across calls (single-occupancy serialization preserved), spawned once and kept ready.
63
+ - On a timeout/crash, respawn is governed by a **circuit breaker**: N kills within T seconds **trips to permanent fail-closed deny** (`plugin.worker.terminated{cause:"respawn-storm"}`, operator reset required) with **exponential backoff** between respawns — so a flapping plugin cannot become a spawn storm.
64
+ - `maxPendingCalls`/`maxMessageBytes` and the kill-switch (`plugins.enabled:false`) apply.
65
+
66
+ ### 2.5 Config + audit (host-computed fields only)
67
+
68
+ - `auth.provider:"plugin"` gains `plugin.isolation: "worker" | "process"` and `plugin.netEnforcement: "require-permission" | "allow-harness"` (default `"require-permission"`). `normalizeConfig` validates fail-closed: `process` requires the `process-isolated` manifest + the capability allowlist; `require-permission` on a Node without `--allow-net` **throws**; the host-fetch URL (when a custom plugin needs key material) must be operator-declared. The `worker`-vs-`process` default stays `worker` for 1.0 back-compat **but the docs steer new high-assurance operators to `process` + `require-permission`**, and the chosen mode is recorded in the audit.
69
+ - **Audit fields are host-computed/enum-only (never child-supplied).** The lifecycle events gain additive `isolation`, `grants` (the **host-computed** granted permission set, not echoed plugin input), and `netEnforcement` — all fixed-enum/host values. Child crash/permission-denial diagnostics map to a **fixed reason enum** (extending `PLUGIN_LOAD_REASONS`), never `error.message`/child output (the core audit sanitizer filters by key *name*, not value, so a free-text field could write a credential into the hash chain — every new field is allowlist/enum). These are on `plugin.*` lifecycle events, **outside** the frozen core protect-event schema, so the 1.0 `api-contract.test.mjs` freeze guard is unaffected (the doc states *why*, so a future maintainer doesn't mistakenly freeze lifecycle events).
70
+
71
+ ### 2.6 The honest model — what 1.1 closes and what it does not
72
+
73
+ For **`process-isolated` + `require-permission` on a `--allow-net` Node**, a malicious signed plugin is contained:
74
+
75
+ - **fs / exec / worker / addons:** kernel-**enforced** denied (`--permission`, no grants); the plugin loads from a `data:` URL with no fs at all.
76
+ - **network:** kernel-**enforced** denied (`--allow-net` absent) → **no credential exfiltration over the network**.
77
+ - **stdio / fd:** **closed** (`ignore` + dedicated IPC, no inheritable fd) → no log/stderr exfil.
78
+ - **env secrets:** scrubbed.
79
+
80
+ **Residual surface (do NOT over-trust beyond this):** (a) a Node **without `--allow-net`** gets **no network containment** — `process-isolated` fails closed there unless the operator explicitly accepts the non-containing `allow-harness`; (b) a plugin that legitimately needs **`networkEgress:true`** is not contained; (c) the host-fetch SSRF guard has a **DNS-rebinding** window; (d) the **credential + injected key material live in child memory** — core-dump/swap exposure is out of scope; (e) `--permission` is a Node runtime control, not an OS sandbox — a Node/V8 escape would defeat it. The `worker-isolated` (1.0) mode is **unchanged** — its trust-only residual stands.
81
+
82
+ ## 3. Explicit non-scope (later minors)
83
+ - Classifier/filter and crypto plugin loading (authProvider only).
84
+ - A live revocation feed / CRL; a plugin registry.
85
+ - Hardening the `allow-harness` fallback to real containment (it can't be, on Node without `--allow-net` — the answer is `require-permission`).
86
+ - OS-level sandboxing (seccomp/namespaces/sandbox-exec) beyond the Node permission model.
87
+ - Replacing `worker-isolated`.
88
+
89
+ ## 4. Backward compatibility
90
+ Additive and opt-in. `worker-isolated`, injection, every provider contract, and the frozen 1.0 API/audit/config schemas are unchanged. `process-isolated` is a new manifest runtime + new `plugin.isolation`/`plugin.netEnforcement` config (defaults preserve 1.0 behavior). The `plugin.*` lifecycle audit events gain additive host-computed fields (outside the frozen protect-event schema — the contract test is unaffected). Promoting `isBlockedAddress` into a core node:-only module is additive (the satellites re-import it; core stays zero runtime dependency). Per strict 1.0 semver, 1.1 is a **minor**.
91
+
92
+ ## 5. 1.1 relationship
93
+ 1.1 strengthens the plugin sandbox from **trust-based** (1.0 worker: trust the signer) to **capability-enforced** (1.1 process: the OS/runtime bounds the signed code) for the new opt-in mode, closing the most-cited 1.0 residual *honestly* — including the parts the first draft got wrong (the harness is not containment; stdio is a leak channel; fail-closed feature detection). It keeps the zero-dependency, fail-closed core promise.
94
+
95
+ ## 6. Threat-model & risk-register deltas
96
+
97
+ | Surface (1.1) | Control | Residual |
98
+ |---|---|---|
99
+ | Malicious signed plugin abusing host fs/exec/worker/addons | `--permission` child, **zero grants**, `data:`-URL load (no fs) | none on a `--permission` Node; a V8/Node escape defeats any runtime control |
100
+ | Credential exfil over the network | `--allow-net` **denied**, **fail-closed feature detection** (`require-permission` → throw if unsupported) | a Node without `--allow-net` → fail closed (or the explicit non-containing `allow-harness`); a `networkEgress:true` plugin |
101
+ | Credential exfil via **stdout/stderr/fd** | `stdio:['ignore','ignore','ignore','ipc']`, no inheritable fd, host never logs child output | none material |
102
+ | Object/proto/fd smuggling over `child_process` IPC | JSON-string-only IPC (`serialization:"json"`), null-proto allowlist sanitizer | none material |
103
+ | Plugin-driven SSRF / outbound pump | host-fetched **operator-declared** URLs only (core SSRF guard), kid-refetch cooldown | DNS-rebinding window on the guard |
104
+ | Audit plaintext leak via new fields | host-computed/enum-only fields, fixed reason enum, no child free-text | none material |
105
+ | Spawn-storm DoS | warm child + circuit breaker + backoff | a tripped breaker denies (fail-closed) until operator reset |
106
+
107
+ Risk IDs (final): **P1-SEC-027** (process-isolated capability **enforcement** — strengthens P1-SEC-024's worker residual: fs/exec/net/stdio now enforced), **P1-SEC-028** (host-mediated key material + the core SSRF guard). *(Renumbered from the proposed 026/027 — P1-SEC-026 is the existing 0.9 OIDC-broker risk.)* The 1.0 P1-SEC-024 row is annotated "enforced in 1.1 for `process-isolated` on a `--allow-net` Node." New §4 exclusions: network containment on `--allow-net`-less Node (fail-closed), `networkEgress:true` plugins, core-dump/swap, OS-level escape.
108
+
109
+ ## 7. Test criteria (mapped to the PR breakdown)
110
+
111
+ ### 7.1 PR1 — the `process-isolated` runtime (capability + stdio + data-URL + fail-closed net)
112
+ - An instrumented signed plugin in `process-isolated` mode is **denied** `fs.readFileSync('/etc/hosts')` (`ERR_ACCESS_DENIED`), cannot spawn a child/worker, and has **no fs grant** (loads from a `data:` URL).
113
+ - **Net red-team:** on a `--allow-net` Node, the plugin's `net.connect` / `fetch` / `dns` and a `process.binding('tcp_wrap')` socket all **fail** (kernel-denied); `createRuntime` with `require-permission` on a Node **without** `--allow-net` **throws at construction** (fail-closed) — not a silent harness downgrade.
114
+ - **stdio/fd red-team:** a plugin writing the credential to `stdout`/`stderr`/`console.error`/fd3 reaches **no host-visible sink** (stdio ignored; the host captures nothing).
115
+ - IPC is JSON-string-only (an attempt to pass a handle/structured-clone object is refused); the load gate + conformance + the fail-closed matrix (timeout→kill, sanitizer, single-occupancy, kill-switch) hold for the process mode; macOS-included cross-platform run.
116
+
117
+ ### 7.2 PR2 — credential containment + host-mediated key material + the core SSRF guard
118
+ - A custom-credential plugin authenticates with the raw credential but, with net+stdio+fs denied, an instrumented exfil attempt (network AND stderr AND fd) reaches **no sink** (assert the credential never leaves).
119
+ - The host-mediated fetch uses the **promoted core** `isBlockedAddress` (a `jwksUri` resolving to a private/metadata range is refused; the plugin never names a URL); the kid-refetch cooldown bounds the outbound rate; the satellites still pass their suites importing the core guard.
120
+
121
+ ### 7.3 PR3 — feature detection + lifecycle + audit + the 1.1.0 release cut
122
+ - `--allow-net` detection via `process.allowedNodeEnvironmentFlags` + the spawn-probe is correct on the dev Node and fail-closed when unsupported; `netEnforcement` audited; the spawn circuit-breaker trips on a respawn storm (and audits it); `normalizeConfig` `plugin.isolation`/`netEnforcement` fail-closed tests.
123
+ - Lifecycle audit additive fields are host-computed/enum-only (a plugin cannot smuggle a value into them); the 1.0 `api-contract.test.mjs` still passes (additive, outside the frozen protect-event schema). Threat-model/risk-register deltas (P1-SEC-026/027), wiki, README; bump core to **1.1.0**; attested publish.
124
+
125
+ ## 8. Suggested PR breakdown (stacked)
126
+ 1. **`process-isolated` runtime** — `createProcessIsolatedAuthProvider`: `data:`-URL load (no fs), `--permission` zero-grant spawn, `stdio:['ignore','ignore','ignore','ipc']` + scrubbed env, JSON-string IPC, the data-minimized wire + host identity, the fail-closed + stdio/net red-team tests. → §7.1
127
+ 2. **Credential containment + core SSRF guard** — promote `isBlockedAddress`/`guardedFetch` into a core node:-only module (satellites re-import it); host-mediated operator-declared key fetch + IPC injection + kid cooldown; the exfil-blocked + no-SSRF tests. → §7.2
128
+ 3. **Feature detection + lifecycle + audit + 1.1.0 cut** — `--allow-net` detect + `netEnforcement` (fail-closed `require-permission` default), warm child + circuit breaker, host-computed audit fields; `plugin.isolation`/`netEnforcement` config; docs EN/KO (this doc, threat-model + risk-register P1-SEC-026/027, the honest-model update), wiki, README; core → 1.1.0, attested publish. → §7.3
@@ -27,6 +27,7 @@
27
27
  | G3 | npm stable | P1 운영 reference, stream-aware enforcement, API stability 강화 | Blocked |
28
28
  | G4 | 0.9.0 observability + interactive-auth 위성 컷 | P1-SEC-026 / P1-OPS-009 mitigated 및 P2-CRYPTO-001 accepted; `haechi-dashboard` + `haechi-auth-oidc` + `haechi-crypto-kms@0.2.0` 테스트 통과; 위성 tarball zero-dep; core 0.9.0 bump(추가적 FORBIDDEN_KEYS audit 강화만) | Pass |
29
29
  | G5 | 1.0.0 stable API contract + signed-plugin sandbox | P1-SEC-024 / P1-SEC-025 mitigated, P2-API-001 / P2-OPS-006 resolved; API freeze + deprecation policy + `tests/api-contract.test.mjs` 통과; Ed25519 signed-plugin contract + `assertAuthProviderConformance` + worker-isolated `authProvider` sandbox 테스트 통과; PR0 위성 peer-range를 `>=0.8.0 <2.0.0`로 확대 및 `check-satellite-peer-ranges.mjs` preflight 게이트 통과; core는 zero runtime dependency 유지; core 1.0.0 bump | Pass |
30
+ | G6 | 1.1.0 plugin capability 강제 (`process-isolated`) | P1-SEC-027 / P1-SEC-028 mitigated; `process-isolated` 런타임(`--permission` 하 자식, 부여 0, `data:` URL 로드, stdio 무시, JSON-string IPC) + fail-closed `--allow-net` 기능 탐지(`netEnforcement:"require-permission"`) + 코어 `haechi/ssrf` 가드 + 호스트 중개 키 자료 + spawn-storm 서킷 브레이커; fs/net/stdio 레드팀 + SSRF + config 테스트 통과(행동 스위트는 `--allow-net` Node에서 실행, 아니면 fail-closed로 skip); API freeze 통과 유지(additive `./ssrf` export + additive config 키); core는 zero runtime dependency 유지; core 1.1.0 bump(additive + opt-in 마이너) | Pass |
30
31
 
31
32
  ## 3. P0 배포 차단 리스크 상태
32
33
 
@@ -114,11 +115,18 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
114
115
 
115
116
  | ID | 리스크 | 상태 | 해소 증거 |
116
117
  |---|---|---|---|
117
- | P1-SEC-024 | 동적 plugin 실행 / sandbox 신뢰 모델: worker sandbox에 로딩된 signed `authProvider` plugin이 host(`fs`/`net`/`process.env`)를 악용하거나 받은 credential을 exfiltrate할 수 있음. **P1-SEC-004의 manifest-only 입장을 대체** — 1.0이 의도적으로 해제하고 새 통제 하에 좁게 동적 로딩 허용 | Mitigated | `packages/plugin/sandbox.mjs` `createSandboxedAuthProvider`(PR #49): `node:worker_threads` memory/crash 격리, 메모리 내 검증된 spawn(경로 재해석/TOCTOU 없음), data-minimized JSON-string wire(credential slice만 전달; host가 keyed-HMAC identity 구성), null-proto claims sanitizer, single-occupancy + correlation-id 동시성, 필수 `timeoutMs` terminate + `resourceLimits`/`maxPendingCalls`/`maxMessageBytes`, kill-switch(`plugins.enabled:false`), 매 respawn마다 전체 게이트 재실행. lifecycle audit(`plugin.load.*`/`authenticate.deny`/`worker.terminated`) + 확장된 `FORBIDDEN_KEYS`; audit identity는 frozen 5 키 `{id,type,subjectHash,issuerHash,provider}`로 projection. 테스트: §7.4 fail-closed + 격리 매트릭스, `auth.provider:"plugin"` `normalizeConfig` fail-closed 테스트, `createRuntime` + proxy auth end-to-end. **잔여:** `node:worker_threads`는 memory/crash 격리 + data-minimization이지 capability sandbox가 아님 — 악의적 signed plugin의 `fs`/`net`/`process.env`는 차단되지 않고 받은 credential을 exfiltrate할 수 있음; 오직 signing/vetting 신뢰 모델로만 통제. 진짜 집행(child-process + Node permission model)은 1.x; worker sandbox는 실제 hostile plugin에 대해 미검증(1.x red-team) |
118
+ | P1-SEC-024 | 동적 plugin 실행 / sandbox 신뢰 모델: worker sandbox에 로딩된 signed `authProvider` plugin이 host(`fs`/`net`/`process.env`)를 악용하거나 받은 credential을 exfiltrate할 수 있음. **P1-SEC-004의 manifest-only 입장을 대체** — 1.0이 의도적으로 해제하고 새 통제 하에 좁게 동적 로딩 허용 | Mitigated | `packages/plugin/sandbox.mjs` `createSandboxedAuthProvider`(PR #49): `node:worker_threads` memory/crash 격리, 메모리 내 검증된 spawn(경로 재해석/TOCTOU 없음), data-minimized JSON-string wire(credential slice만 전달; host가 keyed-HMAC identity 구성), null-proto claims sanitizer, single-occupancy + correlation-id 동시성, 필수 `timeoutMs` terminate + `resourceLimits`/`maxPendingCalls`/`maxMessageBytes`, kill-switch(`plugins.enabled:false`), 매 respawn마다 전체 게이트 재실행. lifecycle audit(`plugin.load.*`/`authenticate.deny`/`worker.terminated`) + 확장된 `FORBIDDEN_KEYS`; audit identity는 frozen 5 키 `{id,type,subjectHash,issuerHash,provider}`로 projection. 테스트: §7.4 fail-closed + 격리 매트릭스, `auth.provider:"plugin"` `normalizeConfig` fail-closed 테스트, `createRuntime` + proxy auth end-to-end. **잔여:** `node:worker_threads`는 memory/crash 격리 + data-minimization이지 capability sandbox가 아님 — 악의적 signed plugin의 `fs`/`net`/`process.env`는 차단되지 않고 받은 credential을 exfiltrate할 수 있음; 오직 signing/vetting 신뢰 모델로만 통제. 진짜 집행(child-process + Node permission model)은 **1.1의 opt-in `process-isolated` 런타임에서 제공됨**(P1-SEC-027, §5.5) `--allow-net` Node에서; `worker_threads`(1.0) 모드는 불변이며 이 잔여를 유지 |
118
119
  | P1-SEC-025 | plugin signing / trust-anchor / revocation lifecycle: signer-key confusion/downgrade/rollback, swap(TOCTOU)된 entry, 또는 revoked/expired signer의 코드 로딩 | Mitigated | `packages/plugin/signing.mjs` `verifySignedPlugin`(PR #48): `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})`에 대한 Ed25519(asymmetric, `node:crypto`) 서명 — `entrySha256` 바인딩(anti-swap), **trust-anchor-only** 키 해석(`signerKeyId` ∉ allowlist면 verify 이전 거부; 알고리즘 Ed25519 고정; signer 집합은 AES rotation 키 파일과 분리), pin + `pluginId`별 version-floor(anti-rollback/malicious-update) + `revokedSignerKeyIds`/`revokedEntrySha256` denylist + `notBefore`/`notAfter` window, 모두 load 시 fail-closed이며 매 respawn마다 재검증. `assertAuthProviderConformance`(`haechi/auth`, `assertCryptoProviderConformance`의 auth 대응)는 load별 randomized vectors를 쓰는 정합성 게이트; host가 call별 PII-safety 재검증. 테스트: §7.3 reason별 거부 매트릭스(각각 `plugin.load.refused{reason}` 방출), conformance negative 테스트, `FORBIDDEN_KEYS` 확장 `sanitizeAudit` 테스트. **잔여:** 운영자가 trust anchor/pin을 curate해야 함; live revocation feed / CRL은 1.x(revocation은 다음 load에 적용; kill-switch가 live plugin을 force-drop) |
119
120
  | P2-API-001 (1.0) | stable-contract freeze + deprecation policy: 불안정 public API / audit-schema drift가 major bump이나 마이그레이션 경로 없이 consumer를 깨뜨림 | Resolved | `docs/current/api-stability.md`(+ko)(PR #47): IN/OUT surface 표, 1.0부터 strict semver, deprecation policy(≥1-minor 유지 + `HAECHI_DEPRECATION_*` runtime-warning 계약 + disclosed-vulnerability in-minor security exception), nested sub-schema 포함 frozen audit event schema + additive `schemaVersion`, config-schema freeze unit(key presence/shape 동결; 더 안전한 default는 허용). `tests/api-contract.test.mjs`가 freeze 가드: subpath별 exports + 전체 audit event(non-null `identity` + `detections[]` 1건) + config key set + `schemaVersion`을 pin; additive 필드는 통과, 제거/개명(top-level OR nested)은 실패, `verifyAuditChain`은 synthetic additive 필드가 있어도 frozen-schema fixture를 검증. **잔여:** major bump은 설계상 깨질 수 있음(문서화된 마이그레이션); disclosed-vulnerability security exception은 advisory + 마이그레이션 경로와 함께 sanctioned in-minor break 허용 |
120
121
  | P2-OPS-006 (1.0) | satellite peer-range / major-tracking 게이트: core를 1.0.0으로 bump하면 모든 위성의 `>=0.8.0 <1.0.0` peer가 unsatisfiable(ERESOLVE)되어 위성 설치가 깨짐 | Resolved | PR0(#46)이 네 위성의 `haechi` peer range를 `>=0.8.0 <2.0.0`로 확대(버전 auth-jwt 0.2.1, crypto-kms 0.2.1, dashboard 0.1.2, auth-oidc 0.1.2; auth-oidc의 `haechi-auth-jwt`도 `<2.0.0`)하고 lockfile 재생성(workspace-lockfile 갭). `scripts/check-satellite-peer-ranges.mjs`는 모든 위성에 대해 `semver.satisfies(coreVersionToPublish, range)`를 단언하는 `release:preflight` 게이트로 core `1.0.0`을 시뮬레이션. `api-stability.md §5`에 위성 peer 상한이 core MAJOR를 추종함을 문서화. **잔여:** core 1.0.0 출시 전에 위성을 재발행해야 1.0.0에 대해 설치됨 |
121
122
 
123
+ ## 5.5 1.1.0 Plugin Capability 강제 리스크 상태
124
+
125
+ | ID | Risk | Status | Resolution evidence |
126
+ |---|---|---|---|
127
+ | P1-SEC-027 | Plugin capability *강제*: 1.0 `worker_threads` sandbox는 memory/crash 격리뿐이라 악의적 signed plugin이 `fs`/`net`을 써서 credential을 exfiltrate할 수 있음. **P1-SEC-024의 수용된 worker 잔여를 강화** — 1.1이 새 opt-in 런타임에 실제 강제 추가 | Mitigated | `packages/plugin/process-sandbox.mjs` `createProcessIsolatedAuthProvider`/`…Sync`(PR #54): signed `authProvider`가 `--permission` 하 자식 `node`에서 **부여 0**(fs/child-process/worker/addons/wasi 없음, `--allow-net` 없음)으로, `data:` URL 로드(fs 권한 없음 → TOCTOU/symlink 표면 없음), `stdio:['ignore','ignore','ignore','ipc']`(stdout/stderr/fd 유출 채널 없음), 정화 env, JSON-string 전용 IPC + 공유 null-proto sanitizer + 호스트측 keyed-HMAC identity로 실행. **Node 26 실측 검증**: plugin의 `fs`/`net`/`fetch`/`dns`/`child_process`/`worker`와 `process.binding('tcp_wrap')` 우회가 모두 `ERR_ACCESS_DENIED`. 네트워크 봉쇄는 **커널 `--allow-net` 거부**(삭제 가능한 JS 하니스가 아님); 기본값 `netEnforcement:"require-permission"`은 강제 못 하는 Node에서 **fail closed**(동작 probe 기능 탐지; PR #54). spawn-storm 서킷 브레이커(PR #56)가 재spawn 제한. lifecycle audit에 호스트 계산/enum 전용 `isolation`/`grants`/`netEnforcement` 추가(PR #56). config: `auth.plugin.isolation:"process"` fail-closed 배선(PR #56). 테스트: fs/net/stdio 레드팀(`--allow-net` 없는 Node에선 fail-closed라 skip) + 상시 실행 fail-closed 계약 + config 매트릭스. **잔여:** `--allow-net` 없는 Node(fail-closed, 미봉쇄); `networkEgress` 부여 plugin; 자식 메모리의 credential/키 자료(core-dump/swap); V8/Node 탈출(런타임 통제일 뿐 OS 샌드박스 아님) |
128
+ | P1-SEC-028 | 호스트 중개 키 자료 + SSRF: 키 자료가 필요한 커스텀 자격증명 plugin이 plugin 주도 SSRF 벡터가 될 수 있고, 코어엔 SSRF 가드가 없었음(위성 복사본은 코어에서 도달 불가) | Mitigated | 새 node:-only, 의존성 0 **`haechi/ssrf`** 코어 모듈(PR #55): `isBlockedAddress`(private/loopback/link-local/metadata), `guardedFetch`(https 전용, DNS 후 재확인, `redirect:"error"`, 본문 제한 + timeout), `createGuardedKeyFetcher`(TTL 캐시 + cooldown). `process-isolated` 런타임의 선택적 `keyMaterial:{url}`은 **호스트**가 **운영자 선언** URL에서 이 가드로 가져와 IPC로 주입 — plugin은 URL 명명 안 함(plugin 주도 SSRF 없음), kid-refetch cooldown이 아웃바운드 비율 제한; blocked-address URL은 fail closed. 테스트: 표준 `isBlockedAddress` 벡터 테이블 + 코어-대-`auth-jwt` parity 가드, `guardedFetch` SSRF 거부/제한, cooldown fail-closed, 런타임 키 주입 + no-SSRF. **잔여:** 위성은 의도적 로컬 복사본 유지(crypto/auth 패키지는 core-ssrf에 런타임 의존 금지; `crypto-kms/ssrf-parity.test.mjs`) — 코어 재import는 연기, drift는 제거가 아니라 parity로 가드; 가드의 DNS-rebinding 창(resolve-then-connect)은 운영자 선언 URL에 대해 수용 |
129
+
122
130
  ## 6. P2 제품/문서 리스크 상태
123
131
 
124
132
  | ID | 기존 리스크 | 상태 | 해소 증거 |