haechi 0.9.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.
- package/README.ko.md +15 -10
- package/README.md +15 -10
- package/docs/current/api-stability.ko.md +87 -41
- package/docs/current/api-stability.md +87 -41
- package/docs/current/release-1.0-implementation-scope.ko.md +170 -0
- package/docs/current/release-1.0-implementation-scope.md +164 -0
- package/docs/current/risk-register-release-gate.ko.md +18 -6
- package/docs/current/risk-register-release-gate.md +18 -6
- package/docs/current/threat-model.ko.md +14 -1
- package/docs/current/threat-model.md +14 -1
- package/package.json +4 -3
- package/packages/audit/index.mjs +13 -1
- package/packages/auth/index.mjs +173 -0
- package/packages/cli/runtime.mjs +184 -5
- package/packages/core/index.mjs +19 -4
- package/packages/plugin/index.mjs +83 -17
- package/packages/plugin/sandbox.mjs +608 -0
- package/packages/plugin/signing.mjs +393 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- 문서 상태: Draft 0.1
|
|
4
4
|
- 작성일: 2026-06-10
|
|
5
|
-
- 기준 버전: 0.
|
|
5
|
+
- 기준 버전: 1.0.0
|
|
6
6
|
|
|
7
7
|
## 1. 보호 대상
|
|
8
8
|
|
|
@@ -65,6 +65,14 @@ Haechi가 보호하려는 주요 자산은 다음이다.
|
|
|
65
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
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
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 게이트로 통제) |
|
|
68
76
|
|
|
69
77
|
## 4. 명시적 제외
|
|
70
78
|
|
|
@@ -86,6 +94,11 @@ Haechi가 보호하려는 주요 자산은 다음이다.
|
|
|
86
94
|
- refresh-token rotation / silent renewal / 장수명 broker 세션 — 0.9 세션은 absolute-TTL + idle-timeout만; `offline_access`는 제거되고 access token은 폐기 (0.9)
|
|
87
95
|
- Dashboard write action(reveal, purge, policy edit) — `haechi-dashboard`는 읽기 전용으로 `POST`/`DELETE` surface 없음; mutation은 reveal governance 하의 CLI에 유지 (0.9)
|
|
88
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)
|
|
89
102
|
|
|
90
103
|
## 5. 남은 운영 전제
|
|
91
104
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- Status: Draft 0.1
|
|
4
4
|
- Date: 2026-06-10
|
|
5
|
-
- Target version: 0.
|
|
5
|
+
- Target version: 1.0.0
|
|
6
6
|
|
|
7
7
|
## 1. Assets Under Protection
|
|
8
8
|
|
|
@@ -65,6 +65,14 @@ The primary assets Haechi protects are:
|
|
|
65
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
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
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) |
|
|
68
76
|
|
|
69
77
|
## 4. Explicit Exclusions
|
|
70
78
|
|
|
@@ -86,6 +94,11 @@ The primary assets Haechi protects are:
|
|
|
86
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)
|
|
87
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)
|
|
88
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)
|
|
89
102
|
|
|
90
103
|
## 5. Remaining Operational Assumptions
|
|
91
104
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "haechi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Experimental developer preview for self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -72,8 +72,9 @@
|
|
|
72
72
|
"sbom": "node scripts/generate-sbom.mjs",
|
|
73
73
|
"checksums": "node scripts/release-checksums.mjs",
|
|
74
74
|
"bench:payload": "node scripts/bench-payload.mjs",
|
|
75
|
-
"
|
|
76
|
-
"release:preflight
|
|
75
|
+
"check:peer-ranges": "node scripts/check-satellite-peer-ranges.mjs",
|
|
76
|
+
"release:preflight": "node scripts/release-preflight.mjs && node scripts/check-satellite-peer-ranges.mjs",
|
|
77
|
+
"release:preflight:npm": "node scripts/release-preflight.mjs --require-npm-auth && node scripts/check-satellite-peer-ranges.mjs",
|
|
77
78
|
"haechi": "node packages/cli/bin/haechi.mjs",
|
|
78
79
|
"demo:init": "node packages/cli/bin/haechi.mjs init --force",
|
|
79
80
|
"demo:protect": "node packages/cli/bin/haechi.mjs protect examples/llm-prompt-filtering/input.json --config haechi.config.json",
|
package/packages/audit/index.mjs
CHANGED
|
@@ -15,7 +15,19 @@ const FORBIDDEN_KEYS = new Set([
|
|
|
15
15
|
// elsewhere, and the broker already self-guards them via its own allowlist
|
|
16
16
|
// projection.
|
|
17
17
|
"access_token", "id_token", "refresh_token", "code", "code_verifier",
|
|
18
|
-
"client_secret", "state", "nonce"
|
|
18
|
+
"client_secret", "state", "nonce",
|
|
19
|
+
// Plugin/claims surface (1.0): a dynamically-loaded auth plugin's lifecycle
|
|
20
|
+
// events carry only ids/hashes/counts, but this additive membership is
|
|
21
|
+
// defense-in-depth so a future plugin event can never leak a raw claim, the
|
|
22
|
+
// received credential/authorization, the signer's signature, or the entry
|
|
23
|
+
// source into the chained log.
|
|
24
|
+
"claims", "subject", "issuer", "credential", "authorization", "signature", "entry",
|
|
25
|
+
// The frozen 1.0 audit-identity contract is exactly {id,type,subjectHash,
|
|
26
|
+
// issuerHash,provider} — scopes/labels are NOT part of it. This additive
|
|
27
|
+
// guard ensures that even if a future code path passes an un-projected
|
|
28
|
+
// identity object, scopes/labels (which can carry attacker-controlled plugin
|
|
29
|
+
// claim values) can never enter the hash-chained audit record.
|
|
30
|
+
"scopes", "labels"
|
|
19
31
|
]);
|
|
20
32
|
|
|
21
33
|
export function createJsonlAuditSink({ path, anchor = null }) {
|
package/packages/auth/index.mjs
CHANGED
|
@@ -212,4 +212,177 @@ export function createBearerAuthProvider({ path, cryptoProvider }) {
|
|
|
212
212
|
};
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// Conformance suite for any authProvider — the CORRECTNESS gate the plugin
|
|
216
|
+
// loader runs before wiring a (sandboxed) auth plugin. It is NOT a malice screen
|
|
217
|
+
// (a signed plugin can detect a fixed test and behave, so vectors are randomized
|
|
218
|
+
// per run and the host re-validates PII-safety per call); it asserts the
|
|
219
|
+
// enumerated security behaviors of the authProvider contract:
|
|
220
|
+
// - missing credential -> null
|
|
221
|
+
// - malformed credential -> null
|
|
222
|
+
// - expired / not-yet-valid credential (clock via injected now) -> null
|
|
223
|
+
// - an internal throw surfaces to the caller as null (never propagates)
|
|
224
|
+
// - a returned identity MUST carry subjectHash AND issuerHash, and MUST NOT
|
|
225
|
+
// contain any field whose value equals the raw input subject or issuer
|
|
226
|
+
// - deny is DETERMINISTIC for identical input
|
|
227
|
+
// - a valid credential -> a well-formed PII-safe identity
|
|
228
|
+
//
|
|
229
|
+
// vectors lets a caller supply the request builders / raw values; by default a
|
|
230
|
+
// randomized-per-run vector set is generated so a plugin cannot hardcode the
|
|
231
|
+
// test. Mirrors assertCryptoProviderConformance's check/assert/failures shape.
|
|
232
|
+
export async function assertAuthProviderConformance(provider, { now = Date.now(), vectors } = {}) {
|
|
233
|
+
const failures = [];
|
|
234
|
+
const check = async (name, fn) => {
|
|
235
|
+
try {
|
|
236
|
+
await fn();
|
|
237
|
+
} catch (error) {
|
|
238
|
+
failures.push(`${name}: ${error.message}`);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const assert = (condition, message) => {
|
|
242
|
+
if (!condition) {
|
|
243
|
+
throw new Error(message);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
if (typeof provider?.authenticate !== "function") {
|
|
248
|
+
throw new Error("authProvider must implement authenticate()");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const v = vectors ?? randomAuthVectors(now);
|
|
252
|
+
|
|
253
|
+
// A contract-conformant authProvider must never throw into the caller. We wrap
|
|
254
|
+
// every call so a throw becomes an explicit failure in the relevant check
|
|
255
|
+
// rather than aborting the whole suite — except the dedicated throw-vector
|
|
256
|
+
// check below, which asserts the provider itself swallowed the throw.
|
|
257
|
+
const callRaw = (request) => provider.authenticate(request);
|
|
258
|
+
const callSafe = async (request) => {
|
|
259
|
+
try {
|
|
260
|
+
return await provider.authenticate(request);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
return { __threw: true, error };
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
await check("missing credential -> null", async () => {
|
|
267
|
+
const result = await callSafe(v.missing.request);
|
|
268
|
+
assert(!result?.__threw, "authenticate threw on a missing credential (must return null)");
|
|
269
|
+
assert(result === null, "missing credential must deny with null");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await check("malformed credential -> null", async () => {
|
|
273
|
+
const result = await callSafe(v.malformed.request);
|
|
274
|
+
assert(!result?.__threw, "authenticate threw on a malformed credential (must return null)");
|
|
275
|
+
assert(result === null, "malformed credential must deny with null");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await check("expired credential -> null", async () => {
|
|
279
|
+
const result = await callSafe(v.expired.request);
|
|
280
|
+
assert(!result?.__threw, "authenticate threw on an expired credential (must return null)");
|
|
281
|
+
assert(result === null, "expired credential must deny with null");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await check("not-yet-valid credential -> null", async () => {
|
|
285
|
+
const result = await callSafe(v.notYetValid.request);
|
|
286
|
+
assert(!result?.__threw, "authenticate threw on a not-yet-valid credential (must return null)");
|
|
287
|
+
assert(result === null, "not-yet-valid credential must deny with null");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await check("an internal throw surfaces to the caller as null (never propagates)", async () => {
|
|
291
|
+
let propagated = false;
|
|
292
|
+
let result;
|
|
293
|
+
try {
|
|
294
|
+
result = await callRaw(v.throwing.request);
|
|
295
|
+
} catch {
|
|
296
|
+
propagated = true;
|
|
297
|
+
}
|
|
298
|
+
assert(!propagated, "authenticate propagated an internal throw (must catch and deny with null)");
|
|
299
|
+
assert(result === null, "an internal error must deny with null, not a non-null identity");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await check("deny is deterministic for identical input", async () => {
|
|
303
|
+
const a = await callSafe(v.malformed.request);
|
|
304
|
+
const b = await callSafe(v.malformed.request);
|
|
305
|
+
assert(!a?.__threw && !b?.__threw, "authenticate threw while checking determinism");
|
|
306
|
+
assert(a === b, "deny is not deterministic for identical input (expected null both times)");
|
|
307
|
+
assert(a === null, "expected a deterministic null deny");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await check("valid credential -> a well-formed, PII-safe identity", async () => {
|
|
311
|
+
const identity = await callSafe(v.valid.request);
|
|
312
|
+
assert(!identity?.__threw, "authenticate threw on a valid credential");
|
|
313
|
+
assert(identity && typeof identity === "object", "a valid credential must return an identity object");
|
|
314
|
+
assert(typeof identity.subjectHash === "string" && identity.subjectHash.length > 0,
|
|
315
|
+
"identity must carry a non-empty subjectHash");
|
|
316
|
+
assert(typeof identity.issuerHash === "string" && identity.issuerHash.length > 0,
|
|
317
|
+
"identity must carry a non-empty issuerHash");
|
|
318
|
+
// PII-safety: no field value may equal the raw input subject or issuer.
|
|
319
|
+
assertNoRawPii(identity, v.valid.subject, v.valid.issuer, assert);
|
|
320
|
+
|
|
321
|
+
// Determinism for the accept path too: identical valid input -> identical
|
|
322
|
+
// identity (a non-deterministic identity breaks audit correlation).
|
|
323
|
+
const again = await callSafe(v.valid.request);
|
|
324
|
+
assert(!again?.__threw, "authenticate threw on a repeated valid credential");
|
|
325
|
+
assert(JSON.stringify(again) === JSON.stringify(identity),
|
|
326
|
+
"accept is not deterministic for identical valid input");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (failures.length > 0) {
|
|
330
|
+
return { ok: false, failures };
|
|
331
|
+
}
|
|
332
|
+
return { ok: true, failures: [] };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Recursively assert no value in the identity equals the raw subject/issuer. The
|
|
336
|
+
// keyed-HMAC subjectHash/issuerHash are derived from these, so an equality
|
|
337
|
+
// against the raw value would mean a PII leak (or an un-hashed passthrough).
|
|
338
|
+
function assertNoRawPii(value, subject, issuer, assert, path = "identity") {
|
|
339
|
+
if (typeof value === "string") {
|
|
340
|
+
assert(value !== subject, `${path} contains the raw subject (PII leak)`);
|
|
341
|
+
assert(value !== issuer, `${path} contains the raw issuer (PII leak)`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (Array.isArray(value)) {
|
|
345
|
+
value.forEach((item, i) => assertNoRawPii(item, subject, issuer, assert, `${path}[${i}]`));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (value && typeof value === "object") {
|
|
349
|
+
for (const [key, item] of Object.entries(value)) {
|
|
350
|
+
assertNoRawPii(item, subject, issuer, assert, `${path}.${key}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function randomAuthVectors(now) {
|
|
356
|
+
const nowMs = typeof now === "number" ? now : Date.parse(now);
|
|
357
|
+
// Per-run random so a plugin cannot hardcode the expected test values.
|
|
358
|
+
const nonce = randomBytes(8).toString("hex");
|
|
359
|
+
const subject = `subj-${randomBytes(6).toString("hex")}`;
|
|
360
|
+
const issuer = `iss-${randomBytes(6).toString("hex")}`;
|
|
361
|
+
const validToken = `valid.${nonce}.${randomBytes(8).toString("hex")}`;
|
|
362
|
+
const expiredToken = `expired.${nonce}.${randomBytes(8).toString("hex")}`;
|
|
363
|
+
const notYetToken = `notyet.${nonce}.${randomBytes(8).toString("hex")}`;
|
|
364
|
+
const malformedToken = `~malformed~${nonce}`;
|
|
365
|
+
const throwToken = `throw.${nonce}`;
|
|
366
|
+
|
|
367
|
+
const bearer = (token) => ({ headers: { authorization: `Bearer ${token}` } });
|
|
368
|
+
// The valid credential encodes the random subject/issuer so any provider that
|
|
369
|
+
// echoes/leaks them into the returned identity is caught by assertNoRawPii.
|
|
370
|
+
// The credential is structured as "valid.<nonce>.<randHex>.<subject>.<issuer>"
|
|
371
|
+
// so a provider COULD extract them — but it MUST then keyed-hash them (not echo
|
|
372
|
+
// them raw) for the PII-safety assertion to pass. This makes the default-vector
|
|
373
|
+
// PII check non-vacuous: a leaking provider fails without custom vectors.
|
|
374
|
+
const validTokenWithPii = `${validToken}.${subject}.${issuer}`;
|
|
375
|
+
return {
|
|
376
|
+
nowMs,
|
|
377
|
+
subject,
|
|
378
|
+
issuer,
|
|
379
|
+
missing: { request: { headers: {} } },
|
|
380
|
+
malformed: { request: bearer(malformedToken), token: malformedToken },
|
|
381
|
+
expired: { request: bearer(expiredToken), token: expiredToken },
|
|
382
|
+
notYetValid: { request: bearer(notYetToken), token: notYetToken },
|
|
383
|
+
throwing: { request: bearer(throwToken), token: throwToken },
|
|
384
|
+
valid: { request: bearer(validTokenWithPii), token: validTokenWithPii, subject, issuer }
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
215
388
|
export { DEFAULT_ALLOWED_LABEL_KEYS };
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -10,8 +10,22 @@ import { loadVerifiedPolicyBundleFileSync } from "../policy-bundle/index.mjs";
|
|
|
10
10
|
import { createProtocolAdapter } from "../protocol-adapters/index.mjs";
|
|
11
11
|
import { applyPrivacyProfile, getPrivacyProfile } from "../privacy-profiles/index.mjs";
|
|
12
12
|
import { createBearerAuthProvider } from "../auth/index.mjs";
|
|
13
|
+
import { createSandboxedAuthProviderSync } from "../plugin/index.mjs";
|
|
13
14
|
import { DEFAULT_PROXY_PORT } from "../proxy/index.mjs";
|
|
14
15
|
|
|
16
|
+
// Capability keys an operator may allowlist for a plugin. Mirrors the plugin
|
|
17
|
+
// manifest's declared-capability set plus the authProvider-specific
|
|
18
|
+
// readsCredentials. Inlined (not imported) to keep the dependency one-way.
|
|
19
|
+
const KNOWN_PLUGIN_CAPABILITIES = new Set([
|
|
20
|
+
"readsPlaintext",
|
|
21
|
+
"writesPlaintext",
|
|
22
|
+
"networkEgress",
|
|
23
|
+
"fileWrite",
|
|
24
|
+
"auditWrite",
|
|
25
|
+
"externalSecrets",
|
|
26
|
+
"readsCredentials"
|
|
27
|
+
]);
|
|
28
|
+
|
|
15
29
|
export const DEFAULT_CONFIG_PATH = "haechi.config.json";
|
|
16
30
|
|
|
17
31
|
export function defaultConfig() {
|
|
@@ -85,6 +99,12 @@ export function defaultConfig() {
|
|
|
85
99
|
store: ".haechi/auth.json",
|
|
86
100
|
allowedLabelKeys: ["team", "env", "tier", "role"]
|
|
87
101
|
},
|
|
102
|
+
// Top-level kill-switch for dynamic plugin loading (1.0 §2.2). Default true;
|
|
103
|
+
// an operator sets `plugins.enabled: false` to force-refuse construction of
|
|
104
|
+
// any sandboxed plugin (a live force-drop, since revocation is next-load).
|
|
105
|
+
plugins: {
|
|
106
|
+
enabled: true
|
|
107
|
+
},
|
|
88
108
|
mcp: {
|
|
89
109
|
allowedMethods: ["initialize", "tools/call", "resources/read", "prompts/get"],
|
|
90
110
|
protectParams: true,
|
|
@@ -128,8 +148,10 @@ export function createRuntime(config, providers = {}) {
|
|
|
128
148
|
// closed at construction rather than deep in a request if a needing feature
|
|
129
149
|
// is configured without it.
|
|
130
150
|
if (typeof cryptoProvider.hmac !== "function"
|
|
131
|
-
&& (normalized.auth.provider === "bearer"
|
|
132
|
-
|
|
151
|
+
&& (normalized.auth.provider === "bearer"
|
|
152
|
+
|| normalized.auth.provider === "plugin"
|
|
153
|
+
|| normalized.tokenVault.deterministic)) {
|
|
154
|
+
throw new Error("cryptoProvider must implement hmac() for bearer/plugin auth / deterministic tokenization");
|
|
133
155
|
}
|
|
134
156
|
const auditSink = providers.auditSink ?? createJsonlAuditSink({
|
|
135
157
|
path: normalized.audit.path,
|
|
@@ -169,7 +191,7 @@ export function createRuntime(config, providers = {}) {
|
|
|
169
191
|
const policyEngine = providers.policyEngine ?? policyProfiles.base.policyEngine;
|
|
170
192
|
assertProvider("policyEngine", policyEngine, ["decide"]);
|
|
171
193
|
|
|
172
|
-
const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider);
|
|
194
|
+
const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider, auditSink);
|
|
173
195
|
|
|
174
196
|
return {
|
|
175
197
|
config: normalized,
|
|
@@ -250,6 +272,10 @@ export function normalizeConfig(config) {
|
|
|
250
272
|
...(config.auth ?? {}),
|
|
251
273
|
allowedLabelKeys: config.auth?.allowedLabelKeys ?? defaultConfig().auth.allowedLabelKeys
|
|
252
274
|
},
|
|
275
|
+
plugins: {
|
|
276
|
+
...defaultConfig().plugins,
|
|
277
|
+
...(config.plugins ?? {})
|
|
278
|
+
},
|
|
253
279
|
mcp: {
|
|
254
280
|
...defaultConfig().mcp,
|
|
255
281
|
...(config.mcp ?? {}),
|
|
@@ -340,7 +366,7 @@ export function normalizeConfig(config) {
|
|
|
340
366
|
throw new Error("limits.upstreamTimeoutMs must be a positive number");
|
|
341
367
|
}
|
|
342
368
|
validatePolicyExtras(merged.policy);
|
|
343
|
-
if (!["none", "bearer", "external"].includes(merged.auth.provider)) {
|
|
369
|
+
if (!["none", "bearer", "external", "plugin"].includes(merged.auth.provider)) {
|
|
344
370
|
throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
|
|
345
371
|
}
|
|
346
372
|
if (typeof merged.auth.store !== "string" || !merged.auth.store.trim()) {
|
|
@@ -350,6 +376,9 @@ export function normalizeConfig(config) {
|
|
|
350
376
|
|| !merged.auth.allowedLabelKeys.every((key) => typeof key === "string" && key.trim())) {
|
|
351
377
|
throw new Error("auth.allowedLabelKeys must be an array of non-empty strings");
|
|
352
378
|
}
|
|
379
|
+
if (merged.auth.provider === "plugin") {
|
|
380
|
+
validatePluginAuthConfig(merged);
|
|
381
|
+
}
|
|
353
382
|
createProtocolAdapter(merged.target);
|
|
354
383
|
return merged;
|
|
355
384
|
}
|
|
@@ -411,7 +440,124 @@ function assertRate(value, label) {
|
|
|
411
440
|
}
|
|
412
441
|
}
|
|
413
442
|
|
|
414
|
-
|
|
443
|
+
// Enumerated, fail-closed validation of auth.provider:"plugin" (1.0 §2.3). Every
|
|
444
|
+
// rule throws a distinct error so a bad option is attributable. Mirrors the
|
|
445
|
+
// keys/tokenVault rigor — no silent degradation.
|
|
446
|
+
function validatePluginAuthConfig(merged) {
|
|
447
|
+
// Kill-switch: refuse to construct any plugin when plugins.enabled is false.
|
|
448
|
+
if (typeof merged.plugins?.enabled !== "boolean") {
|
|
449
|
+
throw new Error("plugins.enabled must be boolean");
|
|
450
|
+
}
|
|
451
|
+
if (merged.plugins.enabled === false) {
|
|
452
|
+
throw new Error("plugins are disabled (plugins.enabled: false); refusing to construct a plugin authProvider");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const plugin = merged.auth.plugin;
|
|
456
|
+
if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) {
|
|
457
|
+
throw new Error("auth.provider 'plugin' requires an auth.plugin object");
|
|
458
|
+
}
|
|
459
|
+
if (typeof plugin.manifestPath !== "string" || !plugin.manifestPath.trim()) {
|
|
460
|
+
throw new Error("auth.plugin.manifestPath must be a non-empty string");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// trustAnchors: a non-empty array of {keyId, publicKey} OR a non-empty object
|
|
464
|
+
// map keyId -> publicKey/anchor.
|
|
465
|
+
const anchors = plugin.trustAnchors;
|
|
466
|
+
if (Array.isArray(anchors)) {
|
|
467
|
+
if (anchors.length === 0) {
|
|
468
|
+
throw new Error("auth.plugin.trustAnchors must be a non-empty array");
|
|
469
|
+
}
|
|
470
|
+
for (const anchor of anchors) {
|
|
471
|
+
if (!anchor || typeof anchor !== "object"
|
|
472
|
+
|| typeof anchor.keyId !== "string" || !anchor.keyId.trim()
|
|
473
|
+
|| anchor.publicKey === undefined || anchor.publicKey === null) {
|
|
474
|
+
throw new Error("each auth.plugin.trustAnchors entry must be { keyId, publicKey }");
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
} else if (anchors && typeof anchors === "object") {
|
|
478
|
+
const keys = Object.keys(anchors);
|
|
479
|
+
if (keys.length === 0) {
|
|
480
|
+
throw new Error("auth.plugin.trustAnchors must be a non-empty object");
|
|
481
|
+
}
|
|
482
|
+
for (const keyId of keys) {
|
|
483
|
+
if (anchors[keyId] === undefined || anchors[keyId] === null) {
|
|
484
|
+
throw new Error(`auth.plugin.trustAnchors.${keyId} must be a public key`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
throw new Error("auth.plugin.trustAnchors must be a non-empty array or object of { keyId, publicKey }");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// allowCapabilities: an array of known capability keys, including readsCredentials.
|
|
492
|
+
if (!Array.isArray(plugin.allowCapabilities) || plugin.allowCapabilities.length === 0) {
|
|
493
|
+
throw new Error("auth.plugin.allowCapabilities must be a non-empty array of capability keys");
|
|
494
|
+
}
|
|
495
|
+
for (const capability of plugin.allowCapabilities) {
|
|
496
|
+
if (typeof capability !== "string" || !KNOWN_PLUGIN_CAPABILITIES.has(capability)) {
|
|
497
|
+
throw new Error(`auth.plugin.allowCapabilities contains an unknown capability: ${capability}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (!plugin.allowCapabilities.includes("readsCredentials")) {
|
|
501
|
+
throw new Error("auth.plugin.allowCapabilities must include readsCredentials for an authProvider");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!Number.isInteger(plugin.timeoutMs) || plugin.timeoutMs <= 0) {
|
|
505
|
+
throw new Error("auth.plugin.timeoutMs must be a positive integer");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const limits = plugin.resourceLimits;
|
|
509
|
+
if (!limits || typeof limits !== "object" || Array.isArray(limits)
|
|
510
|
+
|| !Number.isInteger(limits.maxOldGenerationSizeMb) || limits.maxOldGenerationSizeMb <= 0) {
|
|
511
|
+
throw new Error("auth.plugin.resourceLimits.maxOldGenerationSizeMb must be a positive integer");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (plugin.maxPendingCalls !== undefined
|
|
515
|
+
&& (!Number.isInteger(plugin.maxPendingCalls) || plugin.maxPendingCalls < 1)) {
|
|
516
|
+
throw new Error("auth.plugin.maxPendingCalls must be a positive integer");
|
|
517
|
+
}
|
|
518
|
+
if (plugin.maxMessageBytes !== undefined
|
|
519
|
+
&& (!Number.isInteger(plugin.maxMessageBytes) || plugin.maxMessageBytes < 1)) {
|
|
520
|
+
throw new Error("auth.plugin.maxMessageBytes must be a positive integer");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (plugin.pin !== undefined && plugin.pin !== null) {
|
|
524
|
+
if (typeof plugin.pin !== "object" || Array.isArray(plugin.pin)) {
|
|
525
|
+
throw new Error("auth.plugin.pin must be an object");
|
|
526
|
+
}
|
|
527
|
+
for (const field of ["version", "entrySha256", "manifestSha256"]) {
|
|
528
|
+
if (plugin.pin[field] !== undefined && plugin.pin[field] !== null
|
|
529
|
+
&& (typeof plugin.pin[field] !== "string" || !plugin.pin[field].trim())) {
|
|
530
|
+
throw new Error(`auth.plugin.pin.${field} must be a non-empty string`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (plugin.revoked !== undefined && plugin.revoked !== null) {
|
|
536
|
+
if (typeof plugin.revoked !== "object" || Array.isArray(plugin.revoked)) {
|
|
537
|
+
throw new Error("auth.plugin.revoked must be an object");
|
|
538
|
+
}
|
|
539
|
+
for (const field of ["signerKeyIds", "entrySha256"]) {
|
|
540
|
+
if (plugin.revoked[field] !== undefined
|
|
541
|
+
&& (!Array.isArray(plugin.revoked[field])
|
|
542
|
+
|| !plugin.revoked[field].every((v) => typeof v === "string" && v.trim()))) {
|
|
543
|
+
throw new Error(`auth.plugin.revoked.${field} must be an array of non-empty strings`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (plugin.versionFloor !== undefined && plugin.versionFloor !== null) {
|
|
549
|
+
if (typeof plugin.versionFloor !== "object" || Array.isArray(plugin.versionFloor)) {
|
|
550
|
+
throw new Error("auth.plugin.versionFloor must be an object mapping pluginId -> version");
|
|
551
|
+
}
|
|
552
|
+
for (const [id, floor] of Object.entries(plugin.versionFloor)) {
|
|
553
|
+
if (typeof floor !== "string" || !floor.trim()) {
|
|
554
|
+
throw new Error(`auth.plugin.versionFloor.${id} must be a non-empty version string`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function resolveAuthProvider(config, providers, cryptoProvider, auditSink) {
|
|
415
561
|
if (config.auth.provider === "external") {
|
|
416
562
|
if (typeof providers.authProvider?.authenticate !== "function") {
|
|
417
563
|
throw new Error("auth.provider external requires createRuntime(config, { authProvider })");
|
|
@@ -425,9 +571,42 @@ function resolveAuthProvider(config, providers, cryptoProvider) {
|
|
|
425
571
|
if (config.auth.provider === "bearer") {
|
|
426
572
|
return createBearerAuthProvider({ path: config.auth.store, cryptoProvider });
|
|
427
573
|
}
|
|
574
|
+
if (config.auth.provider === "plugin") {
|
|
575
|
+
const plugin = config.auth.plugin;
|
|
576
|
+
return createSandboxedAuthProviderSync({
|
|
577
|
+
manifestPath: plugin.manifestPath,
|
|
578
|
+
trustAnchors: normalizeTrustAnchors(plugin.trustAnchors),
|
|
579
|
+
allowCapabilities: plugin.allowCapabilities,
|
|
580
|
+
pin: plugin.pin ?? null,
|
|
581
|
+
revoked: plugin.revoked ?? {},
|
|
582
|
+
versionFloor: plugin.versionFloor ?? {},
|
|
583
|
+
timeoutMs: plugin.timeoutMs,
|
|
584
|
+
maxPendingCalls: plugin.maxPendingCalls,
|
|
585
|
+
maxMessageBytes: plugin.maxMessageBytes,
|
|
586
|
+
resourceLimits: plugin.resourceLimits,
|
|
587
|
+
coreVersion: plugin.coreVersion ?? null,
|
|
588
|
+
cryptoProvider,
|
|
589
|
+
auditSink,
|
|
590
|
+
allowedLabelKeys: config.auth.allowedLabelKeys
|
|
591
|
+
});
|
|
592
|
+
}
|
|
428
593
|
return null;
|
|
429
594
|
}
|
|
430
595
|
|
|
596
|
+
// The config form is a non-empty array of { keyId, publicKey } OR an object map.
|
|
597
|
+
// verifySignedPlugin resolves the anchor by signerKeyId against an object map, so
|
|
598
|
+
// normalize an array form into that map here.
|
|
599
|
+
function normalizeTrustAnchors(anchors) {
|
|
600
|
+
if (Array.isArray(anchors)) {
|
|
601
|
+
const map = {};
|
|
602
|
+
for (const anchor of anchors) {
|
|
603
|
+
map[anchor.keyId] = anchor.publicKey;
|
|
604
|
+
}
|
|
605
|
+
return map;
|
|
606
|
+
}
|
|
607
|
+
return anchors;
|
|
608
|
+
}
|
|
609
|
+
|
|
431
610
|
function createConfiguredCryptoProvider(config) {
|
|
432
611
|
if (config.keys.provider === "external") {
|
|
433
612
|
throw new Error("keys.provider external requires createRuntime(config, { cryptoProvider })");
|
package/packages/core/index.mjs
CHANGED
|
@@ -387,14 +387,29 @@ async function replacementFor(segment, detection, decision, { context, cryptoPro
|
|
|
387
387
|
|
|
388
388
|
function buildAuditEvent({ context, mode, enforced, blocked, payload, detections, decisions }) {
|
|
389
389
|
return {
|
|
390
|
+
// Reader-facing audit-event schema version (frozen as part of the 1.0 API
|
|
391
|
+
// contract — see docs/current/api-stability.md). Additive-only: a new field
|
|
392
|
+
// bumps nothing here; only a canonicalization change is a MAJOR schema bump
|
|
393
|
+
// (a new value + a reader migration). It is part of the canonicalized object
|
|
394
|
+
// and so is self-consistent for hash-chain verification of new events.
|
|
395
|
+
schemaVersion: "1",
|
|
390
396
|
id: randomUUID(),
|
|
391
397
|
timestamp: new Date().toISOString(),
|
|
392
398
|
protocol: context.protocol ?? "custom",
|
|
393
399
|
operation: context.operation ?? "protect",
|
|
394
|
-
// PII-safe identity
|
|
395
|
-
//
|
|
396
|
-
// policy
|
|
397
|
-
|
|
400
|
+
// PII-safe identity — projected to the five frozen 1.0 audit-identity keys
|
|
401
|
+
// (id, type, subjectHash, issuerHash, provider). scopes/labels are available
|
|
402
|
+
// to the live policy engine via context.identity but are NOT part of the
|
|
403
|
+
// frozen audit schema (§2.1) and must never be persisted to the hash-chained
|
|
404
|
+
// log (an untrusted plugin's attacker-controlled label/scope value would
|
|
405
|
+
// otherwise enter the immutable audit record via this path).
|
|
406
|
+
identity: context.identity ? {
|
|
407
|
+
id: context.identity.id,
|
|
408
|
+
type: context.identity.type,
|
|
409
|
+
subjectHash: context.identity.subjectHash,
|
|
410
|
+
issuerHash: context.identity.issuerHash,
|
|
411
|
+
provider: context.identity.provider
|
|
412
|
+
} : null,
|
|
398
413
|
profile: context.profile ?? null,
|
|
399
414
|
mode,
|
|
400
415
|
enforced,
|