haechi 0.8.0 → 1.0.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.
@@ -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.)
@@ -69,6 +69,8 @@ npm audit signatures
69
69
  | `.github/workflows/npm-publish.yml` | `haechi` | `v<semver>` | npm provenance publish + 체크섬/증명 release 자산 |
70
70
  | `.github/workflows/crypto-kms-publish.yml` | `haechi-crypto-kms` | `crypto-kms-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
71
71
  | `.github/workflows/auth-jwt-publish.yml` | `haechi-auth-jwt` | `auth-jwt-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
72
+ | `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
73
+ | `.github/workflows/auth-oidc-publish.yml` | `haechi-auth-oidc` | `auth-oidc-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
72
74
 
73
75
  각 publish 워크플로는 `release: published`에서 트리거되지만 **가드**되어 둘이 교차 발화하지 않는다: core job은 `v`로 시작하는 태그에서만 실행되고(그리고 `^v[0-9]+\.[0-9]+\.[0-9]+$` 재검증), satellite job은 `crypto-kms-v…`에서만 실행된다(그리고 `^crypto-kms-v[0-9]+\.[0-9]+\.[0-9]+$` 재검증 **및** 태그 버전이 satellite `package.json` 버전과 일치하는지 검증). npmjs.com Trusted Publisher는 각 패키지의 **특정 워크플로 파일명**에 바인딩된다 — 워크플로 파일 rename은 npm 설정을 갱신할 때까지 OIDC publish를 깨뜨린다.
74
76
 
@@ -89,6 +91,8 @@ Satellite는 npm workspaces 모노레포의 `satellites/*`에 살며 core와 **
89
91
  |---|---|---|---|
90
92
  | `haechi-crypto-kms` | `crypto-kms-v<semver>` | `crypto-kms-publish.yml` | `satellites/crypto-kms/package.json` |
91
93
  | `haechi-auth-jwt` | `auth-jwt-v<semver>` | `auth-jwt-publish.yml` | `satellites/auth-jwt/package.json` |
94
+ | `haechi-dashboard` | `dashboard-v<semver>` | `dashboard-publish.yml` | `satellites/dashboard/package.json` |
95
+ | `haechi-auth-oidc` | `auth-oidc-v<semver>` | `auth-oidc-publish.yml` | `satellites/auth-oidc/package.json` |
92
96
 
93
97
  **satellite 릴리스 검증** (core와 동일한 신뢰 앵커):
94
98
 
@@ -97,7 +101,9 @@ gh attestation verify haechi-crypto-kms-<version>.tgz --repo raeseoklee/haechi
97
101
  npm view haechi-crypto-kms --json # dist.attestations 존재 확인; access "public"
98
102
  ```
99
103
 
100
- **의존성 노트:** `haechi-crypto-kms`는 core를 zero-dependency로 유지한다 — `@aws-sdk/client-kms`는 **optional peer dependency**이며, 실제 AWS 클라이언트를 쓰고 주입하지 않을 때만 lazy import된다. in-memory 또는 주입형 클라이언트를 쓰는 소비자는 SDK를 설치하지 않는다.
104
+ **의존성 노트:** `haechi-crypto-kms`는 core를 zero-dependency로 유지한다 — `@aws-sdk/client-kms`는 **optional peer dependency**이며, 실제 AWS 클라이언트를 쓰고 주입하지 않을 때만 lazy import된다. in-memory 또는 주입형 클라이언트를 쓰는 소비자는 SDK를 설치하지 않는다. 0.2.0의 `./gcp`(`@google-cloud/kms`)와 `./azure`(`@azure/keyvault-keys` + `@azure/identity`) 백엔드도 동일한 optional-peer/lazy-import 모델을 따르며, `./vault` 백엔드는 optional peer가 없다(`node:` `fetch` 전용).
105
+
106
+ **0.9 satellite(새 unscoped 이름 — 첫 태그 *전에* Trusted Publisher 설정):** `haechi-dashboard`와 `haechi-auth-oidc`는 0.9에서 첫 발행되며 위의 satellite별 부트스트랩 순서를 동일하게 따른다. 0.8 satellite와 마찬가지로 unscoped 이름은 첫 OIDC publish 시 확보되므로, 각각의 npmjs.com Trusted Publisher를 첫 태그 **전에** 설정해야 한다 — `raeseoklee/haechi` 저장소와 정확한 워크플로 파일명(`haechi-dashboard`는 `dashboard-publish.yml`, `haechi-auth-oidc`는 `auth-oidc-publish.yml`)을 연결한 뒤, 접두사 태그(`dashboard-v0.1.0`, `auth-oidc-v0.1.0`)를 push하고 GitHub Release를 발행한다. 기존 두 satellite는 이미 부트스트랩된 태그/워크플로를 그대로 사용한다: `haechi-auth-jwt@0.2.0`은 `auth-jwt-v<semver>`(`auth-jwt-publish.yml`), `haechi-crypto-kms@0.2.0`은 `crypto-kms-v<semver>`(`crypto-kms-publish.yml`) — 이 둘은 새 Trusted Publisher 설정이 필요 없다.
101
107
 
102
108
  ## 6. 배포 차단 조건
103
109
 
@@ -69,6 +69,8 @@ npm audit signatures
69
69
  | `.github/workflows/npm-publish.yml` | `haechi` | `v<semver>` | npm provenance publish + checksummed/attested release assets |
70
70
  | `.github/workflows/crypto-kms-publish.yml` | `haechi-crypto-kms` | `crypto-kms-v<semver>` | satellite publish, same signed-artifacts path |
71
71
  | `.github/workflows/auth-jwt-publish.yml` | `haechi-auth-jwt` | `auth-jwt-v<semver>` | satellite publish, same signed-artifacts path |
72
+ | `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, same signed-artifacts path |
73
+ | `.github/workflows/auth-oidc-publish.yml` | `haechi-auth-oidc` | `auth-oidc-v<semver>` | satellite publish, same signed-artifacts path |
72
74
 
73
75
  Each publish workflow triggers on `release: published` but is **guarded** so the two never cross-fire: the core job runs only for tags starting `v` (and re-validates `^v[0-9]+\.[0-9]+\.[0-9]+$`); the satellite job runs only for `crypto-kms-v…` (and re-validates `^crypto-kms-v[0-9]+\.[0-9]+\.[0-9]+$` **and** that the tag version equals the satellite's `package.json` version). The npmjs.com Trusted Publisher for each package is bound to its **specific workflow filename** — renaming a workflow file breaks its OIDC publish until the npm config is updated.
74
76
 
@@ -89,6 +91,8 @@ No manual `npm publish` from a laptop is needed. Because the names are unscoped
89
91
  |---|---|---|---|
90
92
  | `haechi-crypto-kms` | `crypto-kms-v<semver>` | `crypto-kms-publish.yml` | `satellites/crypto-kms/package.json` |
91
93
  | `haechi-auth-jwt` | `auth-jwt-v<semver>` | `auth-jwt-publish.yml` | `satellites/auth-jwt/package.json` |
94
+ | `haechi-dashboard` | `dashboard-v<semver>` | `dashboard-publish.yml` | `satellites/dashboard/package.json` |
95
+ | `haechi-auth-oidc` | `auth-oidc-v<semver>` | `auth-oidc-publish.yml` | `satellites/auth-oidc/package.json` |
92
96
 
93
97
  **Verify a satellite release** (same anchors as core):
94
98
 
@@ -97,7 +101,9 @@ gh attestation verify haechi-crypto-kms-<version>.tgz --repo raeseoklee/haechi
97
101
  npm view haechi-crypto-kms --json # dist.attestations present; access "public"
98
102
  ```
99
103
 
100
- **Dependency note:** `haechi-crypto-kms` keeps core zero-dependency — `@aws-sdk/client-kms` is an **optional peer dependency**, imported lazily only when a real AWS client is used and not injected. Consumers who use the in-memory or an injected client never install the SDK.
104
+ **Dependency note:** `haechi-crypto-kms` keeps core zero-dependency — `@aws-sdk/client-kms` is an **optional peer dependency**, imported lazily only when a real AWS client is used and not injected. Consumers who use the in-memory or an injected client never install the SDK. The 0.2.0 `./gcp` (`@google-cloud/kms`) and `./azure` (`@azure/keyvault-keys` + `@azure/identity`) backends follow the same optional-peer/lazy-import model; the `./vault` backend has zero optional peer (`node:` `fetch` only).
105
+
106
+ **0.9 satellites (new unscoped names — configure Trusted Publisher *before* the first tag):** `haechi-dashboard` and `haechi-auth-oidc` are first-published in 0.9 and follow the same per-satellite bootstrap order above. As with the 0.8 satellites, the unscoped name is claimed on first OIDC publish, so the npmjs.com Trusted Publisher for each must be configured **before** its first tag — link `raeseoklee/haechi` and the exact workflow filename (`dashboard-publish.yml` for `haechi-dashboard`, `auth-oidc-publish.yml` for `haechi-auth-oidc`), then push the prefixed tag (`dashboard-v0.1.0`, `auth-oidc-v0.1.0`) and publish the GitHub Release. The two existing satellites ride their already-bootstrapped tags/workflows: `haechi-auth-jwt@0.2.0` on `auth-jwt-v<semver>` (`auth-jwt-publish.yml`) and `haechi-crypto-kms@0.2.0` on `crypto-kms-v<semver>` (`crypto-kms-publish.yml`) — no new Trusted Publisher configuration is required for those two.
101
107
 
102
108
  ## 6. Deployment block conditions
103
109
 
@@ -1,8 +1,8 @@
1
1
  # Haechi 리스크 레지스터 및 릴리스 게이트
2
2
 
3
- - 문서 상태: Draft 0.3
4
- - 작성일: 2026-06-10
5
- - 기준 버전: 0.7.0
3
+ - 문서 상태: Draft 0.4
4
+ - 작성일: 2026-06-11
5
+ - 기준 버전: 1.0.0
6
6
  - 기준 브랜치: `main`
7
7
 
8
8
  ## 1. 현재 판단
@@ -25,6 +25,8 @@
25
25
  | G1 | GitHub pre-release | P0 코드 리스크 해결, production-ready 표현 없음 | Pass |
26
26
  | G2 | npm developer preview | P0 해결, preflight/SBOM/provenance 경로 준비, npm auth 확인 | Pass (`haechi@0.3.2` 2026-06-10 배포) |
27
27
  | G3 | npm stable | P1 운영 reference, stream-aware enforcement, API stability 강화 | Blocked |
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
+ | 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 |
28
30
 
29
31
  ## 3. P0 배포 차단 리스크 상태
30
32
 
@@ -44,7 +46,7 @@
44
46
  | P1-SEC-001 | KMS/HSM/Vault 미지원 | Resolved for OSS core | `createRuntime(config, { cryptoProvider })` 외부 crypto provider injection, external provider 없으면 fail-closed |
45
47
  | P1-SEC-002 | TokenVault 권한 모델 부족 | Resolved | `revealPolicy: "disabled"` 기본값, `--allow-dev-reveal`, metadata export, retention/purge timestamp |
46
48
  | P1-SEC-003 | audit 무결성 부족 | Resolved | JSONL audit SHA-256 hash chain 및 `verifyAuditChain` |
47
- | P1-SEC-004 | plugin runtime 없음 | Resolved by gating | dynamic runtime 거부, `manifest-only` plugin만 통과 |
49
+ | P1-SEC-004 | plugin runtime 없음 | Resolved by gating (P1-SEC-024이 대체) | dynamic runtime 거부, `manifest-only` plugin만 통과. **1.0에서 P1-SEC-024(§5.4)이 대체:** 1.0은 manifest-only-only 입장을 의도적으로 해제하고, 새 신뢰 통제 하에 signed·capability-gated·worker-isolated·audited `authProvider` plugin에 한해 **좁게** 동적 로딩을 허용 |
48
50
  | P1-SEC-005 | policy conflict 처리 부족 | Resolved | preset block 등 강한 action을 약한 action으로 낮추면 conflict fail-closed |
49
51
  | P1-SEC-006 | regex 중심 필터 정확도 한계 | Resolved for preview | KR RRN checksum, Luhn, unsafe custom regex 제한. ML/classifier plugin은 stable backlog |
50
52
  | P1-SEC-007 | AAD/replay/stream 확장 부족 | Resolved for preview | AAD hash mismatch 명시, streaming 기본 차단. stream sequence/replay cache는 stream support 도입 시 필요 |
@@ -96,6 +98,27 @@
96
98
 
97
99
  base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncation 탐지는 명시적 제외로 threat model에 문서화했다 (0.4+ backlog).
98
100
 
101
+ ## 5.3 0.9.0 Observability + Interactive-Auth 리스크 상태
102
+
103
+ 이 ID들은 0.9.0 위성 컷(`haechi-dashboard`, `haechi-auth-oidc`, `haechi-crypto-kms@0.2.0`)에 한정되며, 0.9.0 섹션으로 namespace되어 위의 동일 번호 P0/P1 행과 구분된다. 증거는 위성 소스, 그 테스트 스위트, 그리고 `docs/current/release-0.9-implementation-scope.md` §6에 정리된 adversarial security review다.
104
+
105
+ | ID | 리스크 | 상태 | 해소 증거 |
106
+ |---|---|---|---|
107
+ | P1-SEC-026 | OIDC broker 세션/로그인 보안: `haechi-auth-oidc`의 login CSRF, authorization-code injection, open-redirect, session fixation, mix-up(잘못된 IdP/RP) | Mitigated | `satellites/auth-oidc/index.mjs`: state-first `/auth/callback`(pre-auth 쿠키 바인딩 pending record를 atomic `take()` + egress 이전 constant-time `state` 비교), PKCE S256, callback에서 새 세션 id 발급(fixation 없음), `returnToAllowlist`(open-redirect 없음), issuer/endpoint pinning + RFC 9207 `iss` 검사 + 공유 `createJwtVerifier` 경유 ID-token `aud`/`azp` 프로파일(mix-up), CSRF-gated non-GET logout. `satellites/auth-oidc/auth-oidc.test.mjs`가 각 deny 케이스 검증; scope §6 adversarial review. **잔여:** multi-origin IdP는 범위 외 |
108
+ | P1-OPS-009 | Dashboard audit 노출: `haechi-dashboard`의 `detections[].path` stored XSS, 미래 필드 audit leak, localhost 뷰어 DNS-rebinding 읽기, remote bind 시 인증 없는 읽기 | Mitigated | `satellites/dashboard/index.mjs` + `assets.mjs`: 엄격 CSP(`require-trusted-types-for 'script'`) + `textContent`-only 렌더링(XSS), `FORBIDDEN_KEYS` 위 재귀적 key-by-key allowlist projection(필드 leak), 요청별 anti-rebinding `Host` allowlist + CORP/COOP same-origin(rebinding), `sessionGuard` **및** TLS 종단을 요구하는 fail-closed remote bind(인증 없는 remote 읽기). `satellites/dashboard/dashboard.test.mjs`; scope §6 adversarial review. **잔여:** remote bind 시 운영자가 TLS 종단을 책임 |
109
+ | P2-CRYPTO-001 | KMS backend egress: `haechi-crypto-kms@0.2.0` Vault/GCP/Azure backend가 key material이나 provider/key-path 상세를 유출하거나 의도치 않은(metadata) 엔드포인트에 도달 가능 | Accepted | `satellites/crypto-kms/{vault,gcp,azure}.mjs`: optional-peer + injected-client 모델과 faithful-mock `assertCryptoProviderConformance`(cross-key·corrupted-blob 거부, HMAC determinism/domain-separation), Vault `fetch`의 satellite-local `isBlockedAddress` SSRF 가드(dev-only `satellites/crypto-kms/ssrf-parity.test.mjs`로 auth-jwt와 parity 유지), generic fail-closed provider-error 매핑(audit에 provider/key-ARN 없음). `{vault,gcp,azure}.test.mjs` + `crypto-kms.test.mjs`; scope §6 adversarial review. **수용된 잔여:** 실제 Vault/GCP/Azure live-backend 검증은 CI 외부; 발행 tarball은 zero runtime dependency 유지 |
110
+
111
+ ## 5.4 1.0.0 Stable API Contract + Signed-Plugin Sandbox 리스크 상태
112
+
113
+ 이 ID들은 1.0.0 stable 컷(API freeze + Ed25519 signed, worker-isolated `authProvider` plugin sandbox)에 한정된다. 권위 있는 threat 행과 범위는 `docs/current/release-1.0-implementation-scope.md` §6이며, 증거는 PR(#46–#49), core 소스, 그리고 테스트 스위트다.
114
+
115
+ | ID | 리스크 | 상태 | 해소 증거 |
116
+ |---|---|---|---|
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-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
+ | 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
+ | 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
+
99
122
  ## 6. P2 제품/문서 리스크 상태
100
123
 
101
124
  | ID | 기존 리스크 | 상태 | 해소 증거 |
@@ -128,9 +151,9 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
128
151
  |---|---|---|
129
152
  | 0.4.0 ✅ | token round-trip and adoption | 2026-06-10 구현 완료: 요청 스코프 response detokenization, deterministic tokenization(파생 키), `haechi mcp-wrap`, `haechi audit-verify`/`haechi status`, injection detection type(기본 allow), `identity`/`authProvider` 계약 예약. `docs/current/release-0.4-implementation-scope.md` 참조 |
130
153
  | 0.5.0 ✅ | streaming hardening | 2026-06-10 출시: bounded cross-frame 버퍼를 사용한 SSE/NDJSON 스트리밍 응답 검사(`streaming.requestMode: inspect`). stream sequence AAD, replay cache, 강화된 원격 배포 가이드는 0.6+으로 이월. `docs/current/release-0.5-implementation-scope.md` 참조 |
131
- | 0.6.0 ✅ | Shipped 2026-06-10 (PRs #17–#19): built-in bearer auth, named policy profiles, model allowlist, request rate limit, PII-safe identity in audit. `docs/current/release-0.6-implementation-scope.md` 참조 |
132
- | 0.7.0 ✅ | Shipped 2026-06-10 (PRs #22–#24): audit head-hash anchoring + external sink contract, cryptoProvider contract hardening + `assertCryptoProviderConformance` + reference KMS adapter, 서명/체크섬된 release artifact. `docs/current/release-0.7-implementation-scope.md` 참조 |
133
- | 0.8.0 (구현 완료; 발행 대기) | ecosystem foundation + satellites | 2026-06-10 구현(PR #27–#29): npm workspaces 모노레포(루트 자기참조 `["."]` + `satellites/*`), `haechi-crypto-kms`(실제 AWS KMS 클라이언트, AWS SDK는 optional peer)와 `haechi-auth-jwt`(헤드리스 JWKS bearer 검증), 각각 provenance-attest 발행 워크플로 보유. core는 zero runtime dependency 유지(CI no-leak + zero-dep + satellite-packaging 게이트). **발행은 운영자가 `@haechi` npm org + 위성별 Trusted Publisher를 생성**해야 진행된다(`docs/current/release-process.md` §5); core `haechi@0.8.0`은 기존 TP로 발행 가능. `docs/current/release-0.8-implementation-scope.md` 참조 |
154
+ | 0.6.0 ✅ | auth and per-client controls | Shipped 2026-06-10 (PRs #17–#19): built-in bearer auth, named policy profiles, model allowlist, request rate limit, PII-safe identity in audit. `docs/current/release-0.6-implementation-scope.md` 참조 |
155
+ | 0.7.0 ✅ | ops hardening | Shipped 2026-06-10 (PRs #22–#24): audit head-hash anchoring + external sink contract, cryptoProvider contract hardening + `assertCryptoProviderConformance` + reference KMS adapter, 서명/체크섬된 release artifact. `docs/current/release-0.7-implementation-scope.md` 참조 |
156
+ | 0.8.0 | ecosystem foundation + satellites | 2026-06-10 출시(PR #27–#32): npm workspaces 모노레포(루트 자기참조 `["."]` + `satellites/*`); `haechi@0.8.0`(attested), `haechi-crypto-kms`, `haechi-auth-jwt`(unscoped `@haechi` scope 점유됨) **발행 완료**. core는 zero runtime dependency 유지(CI no-leak + zero-dep + satellite-packaging 게이트). 위성 `0.1.0`은 이름 생성을 위한 수동 부트스트랩 발행(unattested, `--provenance=false`, `0.3.2`와 동일한 갭)으로 per-name Trusted Publisher 설정 후, `0.1.1`이 attested CI 릴리스(SLSA provenance + sigstore, `gh attestation verify` 통과). `docs/current/release-0.8-implementation-scope.md` 참조 |
134
157
  | 0.9.0 | observability + interactive auth | `haechi-auth-oidc` 전체 authorization-code flow, `haechi-dashboard` 읽기 전용 audit 뷰어(hash-chain 무결성 표시, 요약/검색/타임라인), `haechi-crypto-kms` 추가 백엔드(Vault/GCP/Azure) |
135
158
  | 1.0.0 | stable API contract | migration policy, long-term audit schema, plugin sandbox/runtime conformance 및 allowlist/manifest 통과 외부 auth/classifier package 동적 로딩 |
136
159
 
@@ -1,8 +1,8 @@
1
1
  # Haechi Risk Register and Release Gates
2
2
 
3
- - Status: Draft 0.3
4
- - Date: 2026-06-10
5
- - Target version: 0.7.0
3
+ - Status: Draft 0.4
4
+ - Date: 2026-06-11
5
+ - Target version: 1.0.0
6
6
  - Branch: `main`
7
7
 
8
8
  ## 1. Current Assessment
@@ -25,6 +25,8 @@
25
25
  | G1 | GitHub pre-release | P0 code risks resolved, no production-ready language | Pass |
26
26
  | G2 | npm developer preview | P0 resolved, preflight/SBOM/provenance paths ready, npm auth confirmed | Pass (`haechi@0.3.2` published 2026-06-10) |
27
27
  | G3 | npm stable | P1 production reference, stream-aware enforcement, API stability hardened | Blocked |
28
+ | G4 | 0.9.0 observability + interactive-auth satellite cut | P1-SEC-026 / P1-OPS-009 mitigated and P2-CRYPTO-001 accepted; `haechi-dashboard` + `haechi-auth-oidc` + `haechi-crypto-kms@0.2.0` tests green; satellite tarballs zero-dep; core bumped to 0.9.0 (only an additive FORBIDDEN_KEYS audit hardening) | Pass |
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; the API freeze + deprecation policy + `tests/api-contract.test.mjs` green; the Ed25519 signed-plugin contract + `assertAuthProviderConformance` + the worker-isolated `authProvider` sandbox tests green; PR0 satellite peer-ranges widened to `>=0.8.0 <2.0.0` and the `check-satellite-peer-ranges.mjs` preflight gate green; core stays zero runtime dependency; core bumped to 1.0.0 | Pass |
28
30
 
29
31
  ## 3. P0 Distribution-Blocking Risk Status
30
32
 
@@ -44,7 +46,7 @@
44
46
  | P1-SEC-001 | KMS/HSM/Vault not supported | Resolved for OSS core | `createRuntime(config, { cryptoProvider })` external crypto provider injection; fails closed if no external provider is supplied |
45
47
  | P1-SEC-002 | TokenVault permission model insufficient | Resolved | `revealPolicy: "disabled"` is the default; `--allow-dev-reveal`, metadata export, retention/purge timestamps added |
46
48
  | P1-SEC-003 | Audit integrity insufficient | Resolved | JSONL audit SHA-256 hash chain and `verifyAuditChain` |
47
- | P1-SEC-004 | No plugin runtime | Resolved by gating | Dynamic runtime is rejected; only `manifest-only` plugins pass |
49
+ | P1-SEC-004 | No plugin runtime | Resolved by gating (superseded by P1-SEC-024) | Dynamic runtime is rejected; only `manifest-only` plugins pass. **Superseded in 1.0 by P1-SEC-024 (§5.4):** 1.0 deliberately lifts the manifest-only-only stance, enabling dynamic loading **narrowly** for a signed, capability-gated, worker-isolated, audited `authProvider` plugin under the new trust controls |
48
50
  | P1-SEC-005 | Policy conflict handling insufficient | Resolved | Downgrading a stronger action (e.g., preset block) to a weaker one fails closed on conflict |
49
51
  | P1-SEC-006 | Regex-based filter accuracy limited | Resolved for preview | KR RRN checksum, Luhn, and unsafe custom regex restrictions added. ML/classifier plugin is in the stable backlog |
50
52
  | P1-SEC-007 | AAD/replay/stream extension insufficient | Resolved for preview | AAD hash mismatch is explicit; streaming is blocked by default. Stream sequence/replay cache required when stream support is introduced |
@@ -96,6 +98,27 @@
96
98
 
97
99
  Base64/encoded-value decode inspection, query-string inspection, and audit tail truncation detection are explicitly excluded and documented in the threat model (0.4+ backlog).
98
100
 
101
+ ## 5.3 0.9.0 Observability + Interactive-Auth Risk Status
102
+
103
+ These IDs are scoped to the 0.9.0 satellite cut (`haechi-dashboard`, `haechi-auth-oidc`, `haechi-crypto-kms@0.2.0`); they are namespaced by the 0.9.0 section and are distinct from the like-numbered P0/P1 rows above. Evidence is the satellite source, its test suite, and the adversarial security review captured in `docs/current/release-0.9-implementation-scope.md` §6.
104
+
105
+ | ID | Risk | Status | Resolution evidence |
106
+ |---|---|---|---|
107
+ | P1-SEC-026 | OIDC broker session/login security: login CSRF, authorization-code injection, open-redirect, session fixation, and mix-up (wrong IdP/RP) in `haechi-auth-oidc` | Mitigated | `satellites/auth-oidc/index.mjs`: state-first `/auth/callback` (atomic `take()` of a pre-auth-cookie-bound pending record + constant-time `state` compare before any egress), PKCE S256, fresh session id minted at callback (no fixation), `returnToAllowlist` (no open-redirect), issuer/endpoint pinning + RFC 9207 `iss` check + ID-token `aud`/`azp` profile via the shared `createJwtVerifier` (mix-up), CSRF-gated non-GET logout. `satellites/auth-oidc/auth-oidc.test.mjs` exercises each deny case; adversarial review in scope §6. **Residual:** multi-origin IdP out of scope |
108
+ | P1-OPS-009 | Dashboard audit exposure: stored XSS via `detections[].path`, future-field audit leak, DNS-rebinding read of a localhost viewer, and unauthenticated read on remote bind in `haechi-dashboard` | Mitigated | `satellites/dashboard/index.mjs` + `assets.mjs`: strict CSP (`require-trusted-types-for 'script'`) + `textContent`-only rendering (XSS), recursive key-by-key allowlist projection over `FORBIDDEN_KEYS` (field leak), per-request anti-rebinding `Host` allowlist + CORP/COOP same-origin (rebinding), fail-closed remote bind requiring `sessionGuard` **and** TLS termination (unauthenticated remote read). `satellites/dashboard/dashboard.test.mjs`; adversarial review in scope §6. **Residual:** operator must terminate TLS for remote bind |
109
+ | P2-CRYPTO-001 | KMS backend egress: the `haechi-crypto-kms@0.2.0` Vault/GCP/Azure backends could leak key material or provider/key-path detail or reach an unintended (metadata) endpoint | Accepted | `satellites/crypto-kms/{vault,gcp,azure}.mjs`: optional-peer + injected-client model with faithful-mock `assertCryptoProviderConformance` (cross-key + corrupted-blob rejection, HMAC determinism/domain-separation), satellite-local `isBlockedAddress` SSRF guard on the Vault `fetch` (kept honest by the dev-only `satellites/crypto-kms/ssrf-parity.test.mjs` vs auth-jwt), generic fail-closed provider-error mapping (no provider/key-ARN in audit). `{vault,gcp,azure}.test.mjs` + `crypto-kms.test.mjs`; adversarial review in scope §6. **Residual accepted:** live-backend (real Vault/GCP/Azure) validation is out of CI; the published tarball stays zero runtime dependency |
110
+
111
+ ## 5.4 1.0.0 Stable API Contract + Signed-Plugin Sandbox Risk Status
112
+
113
+ These IDs are scoped to the 1.0.0 stable cut (the API freeze + the Ed25519 signed, worker-isolated `authProvider` plugin sandbox). The authoritative threat rows and scope are `docs/current/release-1.0-implementation-scope.md` §6; evidence is the PRs (#46–#49), the core source, and the test suites.
114
+
115
+ | ID | Risk | Status | Resolution evidence |
116
+ |---|---|---|---|
117
+ | P1-SEC-024 | Dynamic plugin execution / sandbox trust model: a signed `authProvider` plugin loaded into the worker sandbox could abuse the host (`fs`/`net`/`process.env`) or exfiltrate the credential it receives. **Supersedes P1-SEC-004's manifest-only stance** — 1.0 deliberately lifts it, enabling dynamic loading narrowly under new controls | Mitigated | `packages/plugin/sandbox.mjs` `createSandboxedAuthProvider` (PR #49): `node:worker_threads` memory/crash isolation, in-memory verified spawn (no path re-resolution / TOCTOU), data-minimized JSON-string wire (only the credential slice crosses; the host builds the keyed-HMAC identity), null-proto claims sanitizer, single-occupancy + correlation-id concurrency, required `timeoutMs` terminate + `resourceLimits`/`maxPendingCalls`/`maxMessageBytes`, kill-switch (`plugins.enabled:false`), and the full gate re-run on every respawn. Lifecycle audit (`plugin.load.*`/`authenticate.deny`/`worker.terminated`) + extended `FORBIDDEN_KEYS`; the audit identity is projected to the frozen 5 keys `{id,type,subjectHash,issuerHash,provider}`. Tests: the §7.4 fail-closed + isolation matrix, the `auth.provider:"plugin"` `normalizeConfig` fail-closed tests, and the `createRuntime` + proxy auth end-to-end. **Residual:** `node:worker_threads` is memory/crash isolation + data-minimization, NOT a capability sandbox — a malicious signed plugin's `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 (child-process + Node permission model) is 1.x; the worker sandbox is unproven against a real hostile plugin (a 1.x red-team) |
118
+ | P1-SEC-025 | Plugin signing / trust-anchor / revocation lifecycle: signer-key confusion/downgrade/rollback, a swapped (TOCTOU) entry, or a revoked/expired signer loading code | Mitigated | `packages/plugin/signing.mjs` `verifySignedPlugin` (PR #48): Ed25519 (asymmetric, `node:crypto`) signature over `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})` — binding `entrySha256` (anti-swap), **trust-anchor-only** key resolution (refuse before verify if `signerKeyId` ∉ allowlist; algorithm pinned to Ed25519; signer set is separate from the AES rotation key file), pin + per-`pluginId` version-floor (anti-rollback/malicious-update) + `revokedSignerKeyIds`/`revokedEntrySha256` denylists + `notBefore`/`notAfter` window, all fail-closed at load and re-verified on every respawn. `assertAuthProviderConformance` (`haechi/auth`, the auth analog of `assertCryptoProviderConformance`) is a correctness gate with per-load randomized vectors; the host re-validates PII-safety per call. Tests: the §7.3 per-reason refusal matrix (each emits `plugin.load.refused{reason}`), the conformance negative tests, and the `FORBIDDEN_KEYS`-extension `sanitizeAudit` test. **Residual:** the operator must curate trust anchors/pins; a live revocation feed / CRL is 1.x (revocation takes effect at next load; the kill-switch force-drops a live plugin) |
119
+ | P2-API-001 (1.0) | Stable-contract freeze + deprecation policy: an unstable public API / audit-schema drift that breaks consumers without a major bump or a migration path | Resolved | `docs/current/api-stability.md`(+ko) (PR #47): the IN/OUT surface table, strict semver from 1.0, the deprecation policy (≥1-minor retention + `HAECHI_DEPRECATION_*` runtime-warning contract + the disclosed-vulnerability in-minor security exception), the frozen audit event schema including nested sub-schemas + an additive `schemaVersion`, and the config-schema freeze unit (key presence/shape frozen; safer defaults still allowed). `tests/api-contract.test.mjs` is the freeze guard: it pins the per-subpath exports + a full audit event (non-null `identity` + a `detections[]` entry) + the config key set + `schemaVersion`; an additive field passes, a removed/renamed field (top-level OR nested) fails, and `verifyAuditChain` still verifies a frozen-schema fixture with a synthetic additive field. **Residual:** a major bump can break by design (documented migration); the disclosed-vulnerability security exception permits a sanctioned in-minor break with an advisory + migration path |
120
+ | P2-OPS-006 (1.0) | Satellite peer-range / major-tracking gate: bumping core to 1.0.0 makes every satellite's `>=0.8.0 <1.0.0` peer unsatisfiable (ERESOLVE), breaking satellite installs | Resolved | PR0 (#46) widened all four satellites' `haechi` peer range to `>=0.8.0 <2.0.0` (versions auth-jwt 0.2.1, crypto-kms 0.2.1, dashboard 0.1.2, auth-oidc 0.1.2; auth-oidc's `haechi-auth-jwt` likewise to `<2.0.0`) and regenerated the lockfile (the workspace-lockfile gotcha). `scripts/check-satellite-peer-ranges.mjs` is a `release:preflight` gate that asserts `semver.satisfies(coreVersionToPublish, range)` for every satellite, simulating core `1.0.0`. `api-stability.md §5` documents that the satellite peer upper bound tracks the core MAJOR. **Residual:** the satellites must be republished before core 1.0.0 ships so they install against it |
121
+
99
122
  ## 6. P2 Product/Documentation Risk Status
100
123
 
101
124
  | ID | Risk | Status | Resolution evidence |
@@ -130,7 +153,7 @@ All checklist items below were completed for 0.3.2 on 2026-06-10 except the prov
130
153
  | 0.5.0 ✅ | Streaming hardening | Shipped 2026-06-10: SSE/NDJSON streaming response inspection with bounded cross-frame buffer (`streaming.requestMode: inspect`). Stream sequence AAD, replay cache, stronger remote deployment guide deferred to 0.6+. See `docs/current/release-0.5-implementation-scope.md` |
131
154
  | 0.6.0 ✅ | Auth and per-client controls | Shipped 2026-06-10 (PRs #17–#19): built-in bearer auth, named policy profiles, model allowlist, request rate limit, PII-safe identity in audit. See `docs/current/release-0.6-implementation-scope.md` |
132
155
  | 0.7.0 ✅ | Ops hardening | Shipped 2026-06-10 (PRs #22–#24): audit head-hash anchoring + external sink contract, cryptoProvider contract hardening + `assertCryptoProviderConformance` + reference KMS adapter, signed/checksummed release artifacts. See `docs/current/release-0.7-implementation-scope.md` |
133
- | 0.8.0 (implemented; publish pending) | Ecosystem foundation + satellites | Implemented 2026-06-10 (PRs #27–#29): npm workspaces monorepo (root self-member `["."]` + `satellites/*`), `haechi-crypto-kms` (real AWS KMS client, AWS SDK as optional peer) and `haechi-auth-jwt` (headless JWKS bearer verification), each with its own provenance-attested publish workflow. Core stays zero runtime dependency (CI no-leak + zero-dep + satellite-packaging gates). **Publish is gated on the operator creating the `@haechi` npm org + per-satellite Trusted Publishers** (`docs/current/release-process.md` §5); core `haechi@0.8.0` can publish on the existing TP. See `docs/current/release-0.8-implementation-scope.md` |
156
+ | 0.8.0 | Ecosystem foundation + satellites | Shipped 2026-06-10 (PRs #27–#32): npm workspaces monorepo (root self-member `["."]` + `satellites/*`); **published** `haechi@0.8.0` (attested), `haechi-crypto-kms` and `haechi-auth-jwt` (unscoped the `@haechi` scope was taken). Core stays zero runtime dependency (CI no-leak + zero-dep + satellite-packaging gates). Satellite `0.1.0` was a manual bootstrap publish (unattested, `--provenance=false`, mirroring the `0.3.2` gap) to create the names so per-name Trusted Publishers could be configured; `0.1.1` is the first attested CI release (SLSA provenance + sigstore, `gh attestation verify` passes). See `docs/current/release-0.8-implementation-scope.md` |
134
157
  | 0.9.0 | Observability + interactive auth | `haechi-auth-oidc` full authorization-code flow, `haechi-dashboard` read-only audit viewer (hash-chain integrity display, summary/search/timeline), additional `haechi-crypto-kms` backends (Vault/GCP/Azure) |
135
158
  | 1.0.0 | Stable API contract | Migration policy, long-term audit schema, plugin sandbox/runtime conformance, and dynamic loading of external auth/classifier packages that pass allowlist/manifest |
136
159
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  - 문서 상태: Draft 0.1
4
4
  - 작성일: 2026-06-10
5
- - 기준 버전: 0.7.0
5
+ - 기준 버전: 1.0.0
6
6
 
7
7
  ## 1. 보호 대상
8
8
 
@@ -54,6 +54,25 @@ Haechi가 보호하려는 주요 자산은 다음이다.
54
54
  | audit에 원시 credentials/identity 노출 | audit 로그를 통한 token 또는 subject 유출 | Token은 keyed-HMAC 해시로만 저장; identity subject/issuer는 keyed HMAC 처리; `auth_denied` 레코드에 token 미포함 |
55
55
  | token round-trip의 타 토큰 복원 | 클라이언트/요청 간 평문 복구 | detokenization은 opt-in(`detokenizeResponses`)이며 요청 스코프: 같은 요청을 보호하며 발급된 토큰만 복원 |
56
56
  | tool result/응답 내 간접 prompt injection | 심어진 지시문에 의한 agent 조작 | 응답 방향 휴리스틱, 기본 report-only(`injection` action `allow`), 격상은 명시적 정책 선택. 완전 방어 아님 |
57
+ | Haechi 자체 변환 마커 재탐지 | 모델이 echo한 토큰 왕복이 재탐지됨(예: `[TOKEN:…]`가 `secret`으로 차단) → response-enforce에서 `detokenizeResponses` 깨짐 | **응답 방향에서만** Haechi 마커(`[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`) 탐지 제외. 요청 방향은 영향 없으므로 요청에 마커 모양 문자열로 secret을 숨겨 우회할 수 없음. **수용된 잔여:** 악의적 *upstream*이 누출 값을 가짜 응답 마커로 감싸 응답 방향 탐지를 회피할 수 있음 — 응답 검사는 2차 방어(모델은 semi-trusted)이고 제외는 positional(마커 구간만 건너뜀; 인접 값은 여전히 탐지됨) |
58
+ | 응답 메타데이터 오탐 | `enforce` 응답 검사가 envelope 메타데이터를 PII/secret으로 오인해 정상 응답을 차단(예: unix 타임스탬프 `created`가 phone 규칙에, 나노초 `*_duration`이 `card`에 매치) | KR phone 규칙이 구분자·`0` 없는 맨 숫자열을 무시; `chatcmpl-…` 같은 id는 secret 모양 아님; **응답 방향은 bare JSON number leaf를 검사하지 않음**(`*_duration`/count/timestamp/numeric-id — 모델 누출 card/RRN이 아님; 실제 누출은 생성 *텍스트*에 나타나 여전히 검사됨). **수용된 잔여:** 악의적 모델이 bare 응답 숫자로 인코딩해 유출 가능(응답 검사는 2차 방어) — 엄격 운영자는 `responseProtection.scanNumbers: true`로 재활성화 가능. 실제 vLLM·Ollama 응답은 이제 clean; 추가 주의 시 `responseProtection.mode: report-only`(탐지·감사만, 차단 없음) |
59
+ | Dashboard audit 뷰어 XSS — attacker-controlled `detections[].path` (0.9) | `haechi-dashboard`의 stored XSS: `<img onerror>` 같은 요청 JSON key가 client-key 파생 `detections[].path` 필드를 통해 audit 로그에 도달한 뒤 뷰어에서 렌더됨 | 제공 페이지는 DOM을 `createElement` + `textContent`로만 구성(`innerHTML` 보간 사용 안 함)하고, 모든 응답에 `require-trusted-types-for 'script'`를 포함한 엄격 CSP를 부여(잔여 `innerHTML` sink는 브라우저에서 throw). allowlist는 필드 *이름*을, CSP + `textContent`는 악의적 *값*을 각각 독립적으로 무력화. 실질적 잔여 없음 |
60
+ | 뷰어를 통한 audit 필드 leak (미래 필드) (0.9) | 이후 audit 스키마에 추가된 필드가 dashboard API에 그대로 노출되어 의도치 않은 메타데이터 유출 | `/api/events`는 실제 audit 스키마에 대해 **재귀적 key-by-key 필드 allowlist projection**을 수행(`detections`/`identity`/`summary`/`auditIntegrity` 같은 중첩 sub-object를 blind하게 spread하지 않음)하며 core의 `FORBIDDEN_KEYS` 위에 적층됨. 어떤 레벨의 새 중첩 필드든 기본적으로 drop |
61
+ | localhost bind 뷰어에 대한 DNS-rebinding audit JSON 읽기 (0.9) | 운영자가 방문한 사이트가 `127.0.0.1`로 재해석되는 단기 TTL DNS 이름을 게시하면 피해자 브라우저가 same-origin 요청을 보내 공격자 JS가 인증 없는 loopback dashboard에서 audit JSON을 읽음 | 요청별 **anti-rebinding `Host` 헤더 allowlist**(bind 검사와 구분되는 first gate; IPv4-mapped IPv6·trailing-dot·bracketed IPv6 정규화, malformed/중복 `Host` 거부) + `Cross-Origin-Resource-Policy`/`Cross-Origin-Opener-Policy: same-origin`; CORS 헤더는 결코 방출하지 않음. 실질적 잔여 없음 |
62
+ | remote bind 시 인증 없는 audit 읽기 (0.9) | non-loopback host에 bind된 dashboard가 로그인 없이 audit 스트림 노출 | fail-closed precedence: `allowRemoteBind` **및** `sessionGuard`가 있고 **확인된 HTTPS 종단**(`tlsContext`, 또는 신뢰 proxy에서만 `X-Forwarded-Proto`를 신뢰하는 `trustProxy`)이 아니면 remote bind는 throw; Secure/`__Host-` 세션 쿠키는 평문 http로 전송되지 않음. 운영자가 TLS 종단을 책임 |
63
+ | OIDC login CSRF / authorization-code injection / open-redirect / session fixation (0.9) | 공격자가 피해자 broker 세션을 공격자 제어 로그인에 강제하거나, 탈취한 code를 주입하거나, 로그인 후 off-origin으로 redirect하거나, 사전 인지된 세션 id를 고정 | `/auth/callback`은 **state-first**: pre-auth 쿠키 바인딩된 pending record를 atomic `take()`하고 **모든 IdP egress 이전에** constant-time `state` 비교; PKCE S256 필수; callback에서 **새 세션 id 발급**(fixation 없음, pre-auth 쿠키 폐기); 로그인 후 `return_to`는 상대 경로 `returnToAllowlist`로 검증; logout은 non-GET + CSRF-header gated. 단일 IdP 기준 실질적 잔여 없음 |
64
+ | OIDC mix-up (잘못된 IdP / 잘못된 RP) (0.9) | confused-deputy 공격으로 IdP를 바꾸거나 다른 client용으로 발급된 code/token을 재생 | issuer/`token_endpoint`/`jwks_uri`를 `/auth/login`에서 pending record에 pin; RFC 9207 `iss` 응답 파라미터가 pinned issuer와 일치해야 함; `metadata.issuer`가 설정 issuer와 string-equal해야 함; OIDC ID-token `aud`/`azp` 프로파일(`aud`는 `clientId` 포함; multi-valued `aud`는 `azp === clientId` 필요)로 cross-client 차단. multi-origin IdP는 범위 외 |
65
+ | 토큰 엔드포인트 POST(및 Vault `fetch`)를 통한 broker SSRF — cloud metadata (0.9) | discovery와 request 사이에 `169.254.169.254`로 DNS-rebind되는 `token_endpoint`(또는 운영자 제공 `VAULT_ADDR`)가 instance-metadata 자격증명을 유출 | 모든 egress(discovery GET, 공유 verifier 경유 JWKS GET, token-exchange POST, end-session redirect, `haechi-crypto-kms` Vault `fetch`)가 **request 직전**(post-DNS) `lookup` 후 `isBlockedAddress` 재검사를 `redirect: "error"`·bounded body·timeout과 함께 수행. 운영자 신뢰 엔드포인트에 한함 |
66
+ | audit/로그로의 token/secret leak (broker) (0.9) | ID/access/refresh token, `client_secret`, `code`, `state`, `nonce`, raw `sub`가 audit 로그나 client 응답에 기록됨 | broker는 모든 audit 이벤트를 자체 allowlist로 projection해 `subjectHash`/`issuerHash`/`sessionIdHash`(keyed-HMAC) + `provider`/`reasonCode`/timestamp만 방출; core `FORBIDDEN_KEYS`를 broker token/claim key까지 확장; access token은 **폐기**(저장·사용 안 함). 실질적 잔여 없음 |
67
+ | KMS backend egress (Vault HTTP, GCP/Azure SDK) (0.9) | `haechi-crypto-kms` Vault/GCP/Azure backend가 key material이나 provider/key-path 상세를 유출하거나 의도치 않은 엔드포인트에 도달 | optional-peer + injected-client 모델과 **faithful-mock conformance**(cross-key·corrupted-blob 거부, HMAC determinism/domain-separation); Vault `fetch`는 위 satellite-local SSRF 가드 수행; 모든 backend는 provider 오류를 generic fail-closed 오류로 매핑하고 provider/key-ARN 상세를 audit에 기록하지 않음. live-backend 검증은 CI 외부 |
68
+ | 동적 로딩된 악의적/침해된 signed plugin (1.0) | signed `authProvider` plugin이 worker sandbox에 로딩된 뒤 실행 중 host를 악용 | `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})`에 대한 Ed25519 서명, **trust-anchor-only** 키 해석(`signerKeyId`가 allowlist된 anchor가 아니면 verify 이전 거부; 알고리즘은 Ed25519로 고정), pin + `pluginId`별 version-floor + revocation denylist(`revokedSignerKeyIds`/`revokedEntrySha256`) + validity-window 집행, `assertAuthProviderConformance` 정합성 게이트, `node:worker_threads` memory/crash 격리 + per-call timeout-terminate, 전체 lifecycle audit(`plugin.load.*`/`authenticate.deny`/`worker.terminated`). 전체 게이트는 매 respawn마다 재실행. **수용된 잔여:** signed plugin 자신의 `fs`/`fetch`/`process.env`는 차단되지 않으며(`networkEgress: false`는 선언일 뿐 1.0에서 집행 통제 아님) 정당하게 받은 credential을 exfiltrate할 수 있음 — 오직 signing/vetting 신뢰 모델로만 통제됨. 진짜 capability 집행(child-process + Node permission model)은 1.x 경로 |
69
+ | plugin으로의 PII/secret leak (1.0) | request body·crypto 키·token vault·raw claim이 worker 경계를 넘어 유출 | host는 worker에 **credential slice만** 전달(`Authorization` 헤더 / bearer token — request body 절대 안 보냄, crypto 키 절대 안 보냄); wire는 MessagePort 위 평문 JSON 문자열; **null-prototype, own-key-allowlist claims sanitizer**가 `__proto__`/`constructor`/`prototype`을 제거하고 크기를 bound한 뒤 **host**가 `buildExternalIdentity`로 keyed-HMAC identity를 구성(HMAC 키는 worker에 들어가지 않음). **수용된 잔여:** auth plugin이 정당하게 검증하는 credential은 그 plugin에 보임(위 행 참조) |
70
+ | 경계 간 object/proto smuggling (1.0) | 악의적 claims object가 host prototype을 오염시키거나 raw 값을 경계 너머로 밀반입 | JSON-string wire만 사용(structured-clone 없음, `SharedArrayBuffer`/transferables 없음 → shared-memory·object-graph 채널 없음) + `buildExternalIdentity` 이전 null-proto own-key-allowlist sanitizer. 실질적 잔여 없음 |
71
+ | plugin entry의 swap / TOCTOU (1.0) | 서명 검사 후 실행 전에 검증된 entry 바이트가 swap됨(예: symlink 경로 재해석) | 서명이 `entrySha256`을 바인딩; loader는 entry를 **메모리로** 읽어 hash·verify하고 **메모리 내 검증된 소스에서** Worker를 spawn(`eval: true`)하며 검증 후 경로를 재해석하지 않고 symlink entry를 거부. 실질적 잔여 없음 |
72
+ | signer-key confusion / downgrade / rollback / malicious update (1.0) | confused-deputy가 검증 키/알고리즘을 바꾸거나, 신뢰 signer가 같은 anchor로 새/old-vulnerable entry를 조용히 배포 | trust-anchor-only 해석 + Ed25519 알고리즘 고정(alg agility 없음, HS/RS confusion 없음; signer 집합은 별도 curated 목록이며 AES rotation 키 파일이 아님) + pin(`version`/`entrySha256`/`manifestSha256`) + `pluginId`별 version-floor + revocation denylist. **잔여:** 운영자가 anchor/pin을 curate해야 함 |
73
+ | Plugin DoS (1.0) | 버그 있거나 악의적인 signed plugin이 hang/runaway하거나 host를 flood | call별 필수 양의 `timeoutMs`(timeout 시 host가 **worker를 terminate**하고 `null` 반환, lazily respawn), heap `resourceLimits`, `maxPendingCalls`(초과 → deny), `maxMessageBytes`(초과 → deny), single-occupancy worker(per-call terminate가 sibling을 죽일 수 없음). **잔여:** signed plugin이 timeout 내에서 할당된 CPU를 소진할 수 있음(CPU/fd/socket은 1.0에서 bound 안 됨) |
74
+ | 감사되지 않는 code-load (1.0) | tamper-evident 기록 없이 third-party 코드를 로딩/실행 | 모든 load/deny/terminate 결정이 chained audit 이벤트 — `plugin.load.accepted`/`plugin.load.refused{reason}`/`plugin.authenticate.deny{reason}`/`plugin.worker.terminated{cause}`(ids/hashes/counts만); `FORBIDDEN_KEYS`를 확장(`claims`, `subject`, `issuer`, `credential`, `authorization`, `signature`, `entry`, 추가로 `scopes`/`labels`)해 defense-in-depth 적용, audit identity는 frozen 5 키 `{id, type, subjectHash, issuerHash, provider}`로 projection. — |
75
+ | conformance test/prod 괴리 (1.0) | signed plugin이 고정된 conformance 테스트를 감지해 정상 행동한 뒤 운영에서 오작동 | `assertAuthProviderConformance`는 **load별 예측 불가 randomized vectors**를 사용하고, load-bearing하게 **host가 매 call마다 PII-safety를 재검증**(`buildExternalIdentity` + sanitizer가 요청별 실행)하며 load 시점에만 검증하지 않음. **잔여:** conformance-pass가 신뢰성을 함의하지 않음 — 악의적 plugin이 통과 후 오작동 가능(conformance가 아니라 signing+vetting 게이트로 통제) |
57
76
 
58
77
  ## 4. 명시적 제외
59
78
 
@@ -71,6 +90,15 @@ Haechi가 보호하려는 주요 자산은 다음이다.
71
90
  - URL query string 내 민감값 검사 (JSON body만 검사)
72
91
  - 마지막 anchor 이후의 audit tail truncation — `audit.anchor`(0.7)는 anchor가 추가 전용/별도 미디어에 있을 때 마지막 anchor까지의 레코드 삭제를 탐지한다; 마지막 anchor 이후 기록된 레코드와 동일 파일시스템 anchor는 대상에서 제외된다
73
92
  - JSON-RPC batch 메시지 처리 (MCP stdio filter는 batch를 fail-closed로 거부)
93
+ - `haechi-auth-oidc`의 multi-origin / CDN-fronted IdP(issuer host ≠ `token_endpoint`/`jwks_uri` host) — single-origin만 지원, `haechi-auth-jwt`와 동일 제약 (0.9)
94
+ - refresh-token rotation / silent renewal / 장수명 broker 세션 — 0.9 세션은 absolute-TTL + idle-timeout만; `offline_access`는 제거되고 access token은 폐기 (0.9)
95
+ - Dashboard write action(reveal, purge, policy edit) — `haechi-dashboard`는 읽기 전용으로 `POST`/`DELETE` surface 없음; mutation은 reveal governance 하의 CLI에 유지 (0.9)
96
+ - OIDC broker의 `at_hash`/`c_hash` 검증 — broker가 access token을 사용하지 않으므로 정확히 범위 외 (0.9)
97
+ - 악의적 signed plugin에 대한 capability *집행*(`fs`/`net`/`process.env` 차단) — Node permission model 하의 child-process 격리가 필요; 1.0 worker 격리는 memory/crash 격리 + data-minimization만 제공 (1.0)
98
+ - signed `authProvider` plugin이 정당하게 받는 credential의 봉쇄 — de-facto network egress가 있어 exfiltrate 가능; 오직 signing/vetting 신뢰 모델로만 통제 (1.0)
99
+ - classifier/filter 및 crypto plugin 로딩 — 1.0에서 동적 로딩 가능한 plugin kind는 `authProvider`뿐; 다른 kind는 injection-only 유지 (1.0)
100
+ - unsigned dev/loader 경로 — unsigned plugin loader는 없음; 개발은 `createRuntime(config, providers)` 주입 사용 (1.0)
101
+ - live revocation feed / CRL — revocation은 다음 load/restart에 적용(global/per-plugin kill-switch가 live plugin을 즉시 force-drop); live CRL은 1.x (1.0)
74
102
 
75
103
  ## 5. 남은 운영 전제
76
104
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  - Status: Draft 0.1
4
4
  - Date: 2026-06-10
5
- - Target version: 0.7.0
5
+ - Target version: 1.0.0
6
6
 
7
7
  ## 1. Assets Under Protection
8
8
 
@@ -54,6 +54,25 @@ The primary assets Haechi protects are:
54
54
  | Raw credentials/identity in audit | Token or subject leak through the audit log | Tokens stored only as keyed-HMAC hashes; identity subject/issuer are keyed HMAC; `auth_denied` records no token |
55
55
  | Token round-trip restoring foreign tokens | Cross-client/request plaintext recovery | Detokenization is opt-in (`detokenizeResponses`) and request-scoped: only tokens issued while protecting the same request are restored |
56
56
  | Indirect prompt injection in tool results/responses | Agent manipulation via planted instructions | Response-direction heuristics, report-only by default (`injection` action `allow`); escalation is an explicit policy choice. Not a complete defense |
57
+ | Haechi re-detecting its own transform markers | A tokenized round-trip echoed by the model is re-flagged (e.g. `[TOKEN:…]` blocked as a `secret`), breaking `detokenizeResponses` under response-enforce | **Response direction only**, detection skips Haechi markers (`[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`). Request-direction is unaffected, so a marker-shaped string in a request can't smuggle a secret past detection. **Accepted residual:** a hostile *upstream* could wrap a leaked value in a fake response marker to dodge response-direction detection — response inspection is a secondary defense (the model is semi-trusted) and the exclusion is positional (only the marker span is skipped; adjacent values are still detected) |
58
+ | Response-metadata false positives | Response inspection in `enforce` blocks legitimate completions because envelope metadata looks PII/secret-shaped (e.g. a unix-timestamp `created` matching the phone rule, a nanosecond `*_duration` matching `card`) | The KR phone rule ignores bare separator-less non-`0`-led digit runs; ids like `chatcmpl-…` are not secret-shaped; and the **response direction does not scan bare JSON number leaves** (`*_duration`/count/timestamp/numeric-id — never a model-leaked card/RRN; a real leak lands in generated *text*, still inspected). **Accepted residual:** a hostile model could exfiltrate a value encoded as a bare response number (response inspection is a secondary defense) — a strict deployment can opt back in with `responseProtection.scanNumbers: true`. Real vLLM + Ollama responses now scan clean; for extra caution use `responseProtection.mode: report-only` (detect + audit, no block) |
59
+ | Dashboard audit-viewer XSS via attacker-controlled `detections[].path` (0.9) | Stored XSS in `haechi-dashboard`: a request JSON key like `<img onerror>` reaches the audit log via the client-key-derived `detections[].path` field, then renders in the viewer | The served page builds DOM with `createElement` + `textContent` only (never `innerHTML` interpolation), and every response carries a strict CSP including `require-trusted-types-for 'script'` (any stray `innerHTML` sink throws in-browser). The allowlist bounds field *names* and CSP + `textContent` neutralize malicious *values* — independent layers. None material |
60
+ | Audit field leak via the viewer (future field) (0.9) | A field added to the audit schema later is surfaced verbatim by the dashboard API, leaking metadata never meant to be exposed | `/api/events` runs a **recursive, key-by-key field allowlist projection** against the real audit schema (no nested sub-object — `detections`/`identity`/`summary`/`auditIntegrity` — is spread through blind), layered over core's `FORBIDDEN_KEYS`. A new nested field at any level defaults to dropped |
61
+ | DNS-rebinding read of audit JSON from a localhost-bound viewer (0.9) | A site the operator browses publishes a short-TTL DNS name re-resolving to `127.0.0.1`, so the victim's browser makes same-origin requests and the attacker's JS reads the audit JSON from an unauthenticated loopback dashboard | Per-request **anti-rebinding `Host`-header allowlist** (first gate, distinct from the bind check; normalizes IPv4-mapped IPv6, trailing-dot, bracketed IPv6, rejects malformed/duplicate `Host`) plus `Cross-Origin-Resource-Policy`/`Cross-Origin-Opener-Policy: same-origin`; CORS headers are never emitted. None material |
62
+ | Unauthenticated audit read on remote bind (0.9) | The dashboard bound to a non-loopback host exposes the audit stream with no login | Fail-closed precedence: remote bind throws unless `allowRemoteBind` **and** a `sessionGuard` are present **and** confirmed HTTPS termination (a `tlsContext`, or `trustProxy` honoring `X-Forwarded-Proto` only from a trusted proxy); a Secure/`__Host-` session cookie is never sent over plaintext http. Operator must terminate TLS |
63
+ | OIDC login CSRF / authorization-code injection / open-redirect / session fixation (0.9) | An attacker forces a victim's broker session onto an attacker-controlled login, injects a stolen code, redirects post-login off-origin, or fixes a pre-known session id | `/auth/callback` is **state-first**: atomic `take()` of a pre-auth-cookie-bound pending record and constant-time `state` compare **before any IdP egress**; PKCE S256 mandatory; a **fresh session id minted at callback** (no fixation, pre-auth cookie discarded); post-login `return_to` validated against a relative-path `returnToAllowlist`; logout is non-GET + CSRF-header gated. None material for a single IdP |
64
+ | OIDC mix-up (wrong IdP / wrong RP) (0.9) | A confused-deputy attack swaps the IdP or replays a code/token minted for a different client | Issuer/`token_endpoint`/`jwks_uri` are pinned into the pending record at `/auth/login`; the RFC 9207 `iss` response param must equal the pinned issuer; `metadata.issuer` must string-equal the configured issuer; and the OIDC ID-token `aud`/`azp` profile (`aud` must contain `clientId`; multi-valued `aud` requires `azp === clientId`) closes cross-client. Multi-origin IdP out of scope |
65
+ | Broker SSRF to cloud metadata via the token-endpoint POST (and Vault `fetch`) (0.9) | A `token_endpoint` (or operator-supplied `VAULT_ADDR`) that DNS-rebinds to `169.254.169.254` between discovery and request exfiltrates instance-metadata credentials | Every egress (discovery GET, JWKS GET via the shared verifier, token-exchange POST, end-session redirect, and the `haechi-crypto-kms` Vault `fetch`) runs a `lookup`-then-`isBlockedAddress` re-check **immediately before the request** (post-DNS), with `redirect: "error"`, a bounded response body, and a timeout. Operator-trusted endpoints only |
66
+ | Token/secret leak into audit/logs (broker) (0.9) | An ID/access/refresh token, `client_secret`, `code`, `state`, `nonce`, or raw `sub` is written to the audit log or a client response | The broker projects every audit event through its own allowlist and emits only `subjectHash`/`issuerHash`/`sessionIdHash` (keyed-HMAC) + `provider`/`reasonCode`/timestamp; core's `FORBIDDEN_KEYS` is extended to cover the broker token/claim keys; the access token is **discarded** (never stored or used). None material |
67
+ | KMS backend egress (Vault HTTP, GCP/Azure SDK) (0.9) | A `haechi-crypto-kms` Vault/GCP/Azure backend leaks key material or provider/key-path detail, or reaches an unintended endpoint | Optional-peer + injected-client model with **faithful-mock conformance** (cross-key + corrupted-blob rejection, HMAC determinism/domain-separation); the Vault `fetch` runs the satellite-local SSRF guard above; all backends map provider errors to a generic fail-closed error and never write provider/key-ARN detail to audit. Live-backend validation is out of CI |
68
+ | Malicious/compromised signed plugin loaded dynamically (1.0) | A signed `authProvider` plugin is loaded into the worker sandbox and abuses the host once running | Ed25519 signature over `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})`, **trust-anchor-only** key resolution (refuse before verify if `signerKeyId` is not an allowlisted anchor; algorithm pinned to Ed25519), pin + per-`pluginId` version-floor + revocation denylist (`revokedSignerKeyIds`/`revokedEntrySha256`) + validity-window enforcement, `assertAuthProviderConformance` correctness gate, `node:worker_threads` memory/crash isolation + per-call timeout-terminate, and full lifecycle audit (`plugin.load.*`/`authenticate.deny`/`worker.terminated`). The full gate re-runs on every respawn. **Accepted residual:** a signed plugin's own `fs`/`fetch`/`process.env` is NOT blocked — `networkEgress: false` is a declaration, not an enforced control in 1.0 — and the plugin CAN exfiltrate the credential it legitimately receives; this is gated only by the signing/vetting trust model. True capability enforcement (child-process + Node permission model) is the 1.x path |
69
+ | PII/secret leak to a plugin (1.0) | The request body, crypto key, token vault, or a raw claim leaks across the worker boundary | The host sends the worker **only the credential slice** (the `Authorization` header / bearer token — never the request body, never the crypto key); the wire is a plain JSON string over the MessagePort; a **null-prototype, own-key-allowlist claims sanitizer** strips `__proto__`/`constructor`/`prototype` and bounds size before the **host** builds the keyed-HMAC identity via `buildExternalIdentity` (the HMAC key never enters the worker). **Accepted residual:** the credential the auth plugin legitimately validates is visible to it (see the row above) |
70
+ | Cross-boundary object/proto smuggling (1.0) | A hostile claims object pollutes the host prototype or smuggles a raw value back across the boundary | JSON-string wire only (no structured-clone, no `SharedArrayBuffer`/transferables → no shared-memory or object-graph channel) + the null-proto own-key-allowlist sanitizer before `buildExternalIdentity`. None material |
71
+ | Swap / TOCTOU on the plugin entry (1.0) | The verified entry bytes are swapped (e.g. a symlinked path re-resolved) after signature check but before execution | The signature binds `entrySha256`; the loader reads the entry **into memory**, hashes, verifies, and spawns the Worker **from the in-memory verified source** (`eval: true`), never re-resolving the path after verification, and refuses a symlinked entry. None material |
72
+ | Signer-key confusion / downgrade / rollback / malicious update (1.0) | A confused-deputy swaps the verification key/algorithm, or a trusted signer silently ships a new or old-vulnerable entry under the same anchor | Trust-anchor-only resolution + pinned Ed25519 algorithm (no alg agility, no HS/RS confusion; the signer set is a separate curated list, never the AES rotation key file) + pin (`version`/`entrySha256`/`manifestSha256`) + per-`pluginId` version-floor + revocation denylist. **Residual:** the operator must curate anchors/pins |
73
+ | Plugin DoS (1.0) | A buggy or hostile signed plugin hangs, runs away, or floods the host | Required positive `timeoutMs` per call (on timeout the host **terminates the worker** and returns `null`, respawning lazily), heap `resourceLimits`, `maxPendingCalls` (excess → deny), `maxMessageBytes` (oversized → deny), single-occupancy worker (a per-call terminate can never kill a sibling). **Residual:** a signed plugin can burn its allotted CPU within the timeout (CPU/fd/socket are not bounded in 1.0) |
74
+ | Unaudited code-load (1.0) | Loading or executing third-party code without a tamper-evident record | Every load/deny/terminate decision is a chained audit event — `plugin.load.accepted`/`plugin.load.refused{reason}`/`plugin.authenticate.deny{reason}`/`plugin.worker.terminated{cause}` (ids/hashes/counts only); `FORBIDDEN_KEYS` is extended (`claims`, `subject`, `issuer`, `credential`, `authorization`, `signature`, `entry`, plus `scopes`/`labels`) as defense-in-depth, and the audit identity is projected to the frozen 5 keys `{id, type, subjectHash, issuerHash, provider}`. — |
75
+ | Conformance test/prod divergence (1.0) | A signed plugin detects a fixed conformance test and behaves, then misbehaves in production | `assertAuthProviderConformance` uses **unpredictable per-load randomized vectors**, and — load-bearing — the **host re-validates PII-safety on every call** (`buildExternalIdentity` + the sanitizer run per request), not just at load. **Residual:** conformance-pass does not imply trustworthiness — a malicious plugin can pass then misbehave (covered by the signing+vetting gate, not by conformance) |
57
76
 
58
77
  ## 4. Explicit Exclusions
59
78
 
@@ -71,6 +90,15 @@ The primary assets Haechi protects are:
71
90
  - Detection of sensitive values in URL query strings (JSON body only)
72
91
  - Audit tail truncation beyond the last anchor — `audit.anchor` (0.7) detects deletion of records back to the last anchor when the anchor is on append-only/separate media; records written after the last anchor, and same-filesystem anchors, are not covered
73
92
  - JSON-RPC batch message processing (the MCP stdio filter rejects batches fail-closed)
93
+ - Multi-origin / CDN-fronted IdP for `haechi-auth-oidc` (issuer host ≠ `token_endpoint`/`jwks_uri` host) — single-origin only, same constraint as `haechi-auth-jwt` (0.9)
94
+ - Refresh-token rotation / silent renewal / long-lived broker sessions — 0.9 sessions are absolute-TTL + idle-timeout only; `offline_access` is stripped and the access token is discarded (0.9)
95
+ - Dashboard write actions (reveal, purge, policy edits) — `haechi-dashboard` is read-only with no `POST`/`DELETE` surface; mutation stays in the CLI under reveal governance (0.9)
96
+ - `at_hash`/`c_hash` validation in the OIDC broker — out of scope precisely because the broker never uses the access token (0.9)
97
+ - Capability *enforcement* against a malicious signed plugin (blocking `fs`/`net`/`process.env`) — needs child-process isolation under the Node permission model; 1.0 worker isolation is memory/crash isolation + data-minimization only (1.0)
98
+ - Containment of the credential a signed `authProvider` plugin legitimately receives — it has de-facto network egress and can exfiltrate it; gated only by the signing/vetting trust model (1.0)
99
+ - Classifier/filter and crypto plugin loading — `authProvider` is the only dynamically loadable plugin kind in 1.0; other kinds stay injection-only (1.0)
100
+ - An unsigned dev/loader path — there is no unsigned plugin loader; development uses `createRuntime(config, providers)` injection (1.0)
101
+ - A live revocation feed / CRL — revocation takes effect at the next load/restart (a global/per-plugin kill-switch force-drops a live plugin immediately); a live CRL is 1.x (1.0)
74
102
 
75
103
  ## 5. Remaining Operational Assumptions
76
104