haechi 1.3.3 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +46 -4
- package/README.md +51 -5
- package/docs/current/api-stability.ko.md +2 -2
- package/docs/current/api-stability.md +2 -2
- package/docs/current/config-version.ko.md +1 -1
- package/docs/current/config-version.md +2 -2
- package/docs/current/configuration.ko.md +2 -2
- package/docs/current/configuration.md +2 -2
- package/docs/current/operations-runbook.ko.md +1 -1
- package/docs/current/operations-runbook.md +1 -1
- package/docs/current/plugin-signing-and-trust.ko.md +143 -0
- package/docs/current/plugin-signing-and-trust.md +148 -0
- package/docs/current/release-process.ko.md +1 -1
- package/docs/current/release-process.md +1 -1
- package/docs/current/risk-register-release-gate.ko.md +7 -5
- package/docs/current/risk-register-release-gate.md +6 -4
- package/docs/current/shared-responsibility.ko.md +1 -1
- package/docs/current/shared-responsibility.md +1 -1
- package/docs/current/threat-model.ko.md +1 -1
- package/docs/current/threat-model.md +1 -1
- package/package.json +1 -1
- package/packages/audit/index.mjs +98 -32
- package/packages/cli/bin/haechi.mjs +275 -3
- package/packages/token-vault/index.mjs +167 -56
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Haechi 리스크 레지스터 및 릴리스 게이트
|
|
2
2
|
|
|
3
|
-
- 문서 상태: Living document(core 1.
|
|
3
|
+
- 문서 상태: Living document(core 1.5.x 추적)
|
|
4
4
|
- 작성일: 2026-06-16
|
|
5
|
-
- 기준 버전: 1.
|
|
5
|
+
- 기준 버전: 1.5.x
|
|
6
6
|
- 기준 브랜치: `main`
|
|
7
7
|
|
|
8
8
|
## 1. 현재 판단
|
|
@@ -14,9 +14,9 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
|
|
|
14
14
|
| 구분 | 판단 | 이유 |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| GitHub public | 허용 | 보안 한계, threat model, shared responsibility가 문서화됨 |
|
|
17
|
-
| GitHub release/tag | 허용 (`v1.
|
|
18
|
-
| npm stable | `haechi@1.
|
|
19
|
-
| production use | 운영자 게이트; `1.
|
|
17
|
+
| GitHub release/tag | 허용 (`v1.5.0` 릴리스됨) | `v1.5.0`이 현재 릴리스(additive minor — 수평 확장을 위한 주입 가능한 audit/token-vault 저장소 시임); §5.7 / §5.8 항목은 모두 Resolved 유지, G9–G12는 Pass |
|
|
18
|
+
| npm stable | `haechi@1.5.0` publish됨 | `1.5.0`은 `1.4.x` 기준 위에 `createAuditSink`/`createTokenVault` 저장소 시임(파일 기본값 바이트 동일)을 더한 attested OIDC publish; config/API 파괴 없음(`configVersion`은 `1` 유지) |
|
|
19
|
+
| production use | 운영자 게이트; `1.5.0`로 업그레이드 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; 여러 replica를 운영하는 운영자는 공유 저장소(`haechi-store-redis` 위성)를 주입해 audit 해시 체인과 token vault가 플릿 전체에서 유지되도록 해야 함 |
|
|
20
20
|
|
|
21
21
|
## 2. 릴리스 게이트
|
|
22
22
|
|
|
@@ -33,6 +33,8 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
|
|
|
33
33
|
| G8 | 1.3.0 백엔드 + 탐지 커버리지 확장 | **Anthropic Messages API**(`/v1/messages`, content-block + SSE `delta.text`, `event:` 라인 보존 재직렬화)와 **Google Gemini API**(model-in-path `:generateContent`/`:streamGenerateContent`, 기존 정확-매칭 어댑터를 바이트 동일하게 두는 additive `:method`-suffix 라우트 매처) 프로토콜 어댑터 추가; 탐지 커버리지 확장 — 클라우드/SaaS provider 키(OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored)와 국제 PII(FR/ES/JP + IT/SG/IN/DE/NL 국가 ID, 체크섬 validator), 각 하드블록-대-dial-eligible 결정은 측정된 충돌률 기반(하드블록은 비숫자 앵커 또는 비현실적으로 드문 형태가 필요; 흔한 길이의 bare-digit run은 allowlist로 정리 가능 유지); `bench:throughput` proxy 부하 벤치; `haechi-ratelimit-redis` 공유 저장소 rate-limiter 위성(WS3 시임의 운영 소비자; proxy가 이제 `rateLimiter.allow`를 `await`); `haechi-dashboard`가 요청별 `correlationId` 노출. 모든 변경은 additive — 새 `target.type`/탐지타입/`privacy.profile` *값*이며 새 config 키가 아님(`configVersion`은 `1` 유지); `tests/api-contract.test.mjs` 통과; core는 zero runtime dependency 유지; core 1.3.0 bump(additive 마이너) | Pass |
|
|
34
34
|
| G9 | 2026-06-16 전체 코드리뷰 보완 게이트 (1.3.1로 발행) | `P0-CR-001` 및 `P1-CR-002`부터 `P1-CR-005`까지 해결 또는 책임자 명시 수용; P2 항목은 해결 또는 명시적 non-blocking 근거와 일정 기록; 연결된 등록부 갱신. **13개 `P*-CR-*` 항목이 모두 Resolved이며(§5.7) `haechi@1.3.1`(2026-06-16, attested OIDC publish)로 발행되었습니다; core가 1.3.0 → 1.3.1로 bump(patch, 보완 전용 — API/config 표면 변경 없음, `configVersion`은 `1` 유지)되었습니다.** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
35
35
|
| G10 | 2026-06-16 코드리뷰 round 2 (CR2) 보완 게이트 | CR2 등록부(`code-review-risk-register-2026-06-16-round2.md`, §5.8)는 **P0/P1을 발견하지 못했습니다**; 세 개의 P2(`CR2-001` 프록시 upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply 경계)와 P3 묶음(`CR2-004..008`)이 모두 **Resolved이며 `haechi@1.3.2`로 발행되었고**(`CR2-009` won't-fix, `CR2-010` accepted) 연결된 등록부가 갱신되었습니다. | Pass (`haechi@1.3.2`, 2026-06-16) |
|
|
36
|
+
| G11 | 1.4.0 signed-plugin 저작 CLI | 1.0 Ed25519 trust gate를 위한 1차 저작 CLI — `plugin-keygen`(개인키 `0600`, 공개키 = trust anchor), `plugin-sign`(정확한 entry 바이트 바인딩), `plugin-verify`(런타임 동등 검증, fail-closed, `--allow-capability`); 개인키가 stdout/audit로 유출되지 않음; 적대적 검증 완료; `plugin-signing-and-trust.md` 큐레이션 런북이 P1-SEC-025 "운영자가 앵커를 큐레이션해야 함" 잔여를 해소. additive CLI 표면(config/API 파괴 없음, `configVersion`은 `1` 유지); `tests/api-contract.test.mjs` green; 코어는 zero runtime dependency 유지; 코어 1.3.3 → 1.4.0(additive minor)로 bump. | Pass (`haechi@1.4.0`, 2026-06-17) |
|
|
37
|
+
| G12 | 1.5.0 수평 확장 저장소 시임 | audit sink와 token vault가 주입 가능한 **store**를 갖게 되어, 공유 저장소가 sha256 해시 체인 + token vault를 replica 전반에서 뒷받침할 수 있음(프로세스별 / 단일 writer 플릿 한계를 해소). `createAuditSink({store})` / `createTokenVault({store})` + 기본 `createFileAuditStore`/`createFileTokenStore`; 보안에 결정적인 chaining / `sanitizeAudit` / reveal governance / retention은 코어에 남고, store는 배타적 read-previous+persist(audit) / mutate+read(vault) 프리미티브만 추상화. 적대적 검증 완료: 파일 기본값 바이트 동일, chain 연산은 이전과 diff 동일, 비파일 store에서도 시임 동작, 동시 append/tokenize가 비분기·무손실 유지, CR2-002 audit-no-plaintext 유지. 새 export는 `api-stability.md` + `tests/api-contract.test.mjs`에 frozen; `createJsonlAuditSink`/`createLocalTokenVault`는 하위호환 래퍼; 코어는 zero runtime dependency 유지; 코어 1.4.0 → 1.5.0(additive minor)로 bump. `haechi-store-redis` 위성이 운영 소비자. | Pass (`haechi@1.5.0`, 2026-06-17) |
|
|
36
38
|
|
|
37
39
|
## 3. P0 배포 차단 리스크 상태
|
|
38
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haechi Risk Register and Release Gates
|
|
2
2
|
|
|
3
|
-
- Status: Living document (tracks core 1.
|
|
3
|
+
- Status: Living document (tracks core 1.5.x)
|
|
4
4
|
- Date: 2026-06-16
|
|
5
5
|
- Target version: 1.3.x
|
|
6
6
|
- Branch: `main`
|
|
@@ -14,9 +14,9 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
14
14
|
| Category | Judgment | Rationale |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| GitHub public | Allowed | Security limitations, threat model, and shared responsibility are documented |
|
|
17
|
-
| GitHub release/tag | Allowed (`v1.
|
|
18
|
-
| npm stable | `haechi@1.
|
|
19
|
-
| Production use | Operator-gated; upgrade to `1.
|
|
17
|
+
| GitHub release/tag | Allowed (`v1.5.0` released) | `v1.5.0` is the current release (additive minor — injectable audit/token-vault store seams for horizontal scale); all §5.7 / §5.8 findings remain Resolved and G9–G12 are Pass |
|
|
18
|
+
| npm stable | `haechi@1.5.0` published | `1.5.0` is an attested OIDC publish adding `createAuditSink`/`createTokenVault` store seams (file defaults byte-identical) over the `1.4.x` baseline; no config/API break (`configVersion` stays `1`) |
|
|
19
|
+
| Production use | Operator-gated; upgrade to `1.5.0` | Supported only with operator network controls, authz/authn, and key custody; operators running multiple replicas should inject a shared store (the `haechi-store-redis` satellite) so the audit hash chain and token vault hold across the fleet |
|
|
20
20
|
|
|
21
21
|
## 2. Release Gates
|
|
22
22
|
|
|
@@ -33,6 +33,8 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
33
33
|
| G8 | 1.3.0 backend + detection coverage expansion | New protocol adapters for the **Anthropic Messages API** (`/v1/messages`, content-block + SSE `delta.text` with `event:`-line-preserving re-serialize) and the **Google Gemini API** (model-in-path `:generateContent`/`:streamGenerateContent` via an additive `:method`-suffix route matcher that leaves the exact-match adapters byte-identical); detection coverage expansion — cloud/SaaS provider keys (OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored) and international PII (FR/ES/JP + IT/SG/IN/DE/NL national IDs with checksum validators), each hard-block-vs-dial-eligible decision driven by measured collision rates (a non-numeric anchor or implausibly-rare shape is required for hard-block; a bare-digit run over a common length stays allowlist-clearable); a `bench:throughput` proxy load benchmark; the `haechi-ratelimit-redis` shared-store rate-limiter satellite (the WS3 seam's production consumer; the proxy now `await`s `rateLimiter.allow`); `haechi-dashboard` surfaces the per-request `correlationId`. Every change is additive — new `target.type`/detection-type/`privacy.profile` *values*, not new config keys (`configVersion` stays `1`); `tests/api-contract.test.mjs` green; core stays zero runtime dependency; core bumped to 1.3.0 (additive minor) | Pass |
|
|
34
34
|
| G9 | 2026-06-16 full code-review remediation gate (shipped in 1.3.1) | `P0-CR-001` and `P1-CR-002` through `P1-CR-005` resolved or formally accepted; P2 items either resolved or scheduled with explicit non-blocking rationale; linked register updated. **All 13 `P*-CR-*` findings are Resolved (§5.7) and shipped in `haechi@1.3.1` (2026-06-16, attested OIDC publish); core bumped 1.3.0 → 1.3.1 (patch, remediation-only — no API/config surface change, `configVersion` stays `1`).** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
35
35
|
| G10 | 2026-06-16 code-review round 2 (CR2) remediation gate | The CR2 register (`code-review-risk-register-2026-06-16-round2.md`, §5.8) found **no P0/P1**; its three P2s (`CR2-001` proxy upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply bound) plus the P3 cluster (`CR2-004..008`) are all **Resolved and shipped in `haechi@1.3.2`** (`CR2-009` won't-fix, `CR2-010` accepted) and the linked register is updated. | Pass (`haechi@1.3.2`, 2026-06-16) |
|
|
36
|
+
| G11 | 1.4.0 signed-plugin authoring CLI | First-party CLI for the 1.0 Ed25519 trust gate — `plugin-keygen` (private key `0600`, public key = trust anchor), `plugin-sign` (binds the exact entry bytes), `plugin-verify` (runtime-equivalent verification, fail-closed, `--allow-capability`); no private-key leak to stdout/audit; adversarially verified; the `plugin-signing-and-trust.md` curation runbook closes the P1-SEC-025 "operator must curate anchors" residual. Additive CLI surface (no config/API break, `configVersion` stays `1`); `tests/api-contract.test.mjs` green; core stays zero runtime dependency; core bumped 1.3.3 → 1.4.0 (additive minor). | Pass (`haechi@1.4.0`, 2026-06-17) |
|
|
37
|
+
| G12 | 1.5.0 horizontal-scale store seams | The audit sink and token vault gain an injectable **store** so a shared store can back the sha256 hash chain + token vault across replicas (closes the per-process / single-writer fleet limitations). `createAuditSink({store})` / `createTokenVault({store})` + the default `createFileAuditStore`/`createFileTokenStore`; the security-critical chaining / `sanitizeAudit` / reveal governance / retention stay in core, the store only abstracts the exclusive read-previous+persist (audit) / mutate+read (vault) primitives. Adversarially verified: file defaults byte-identical, chain math diff-identical to prior, the seam works for a non-file store, concurrent appends/tokenize stay non-forked/lossless, CR2-002 audit-no-plaintext intact. New exports frozen in `api-stability.md` + `tests/api-contract.test.mjs`; `createJsonlAuditSink`/`createLocalTokenVault` are back-compat wrappers; core stays zero runtime dependency; core bumped 1.4.0 → 1.5.0 (additive minor). The `haechi-store-redis` satellite is the production consumer. | Pass (`haechi@1.5.0`, 2026-06-17) |
|
|
36
38
|
|
|
37
39
|
## 3. P0 Distribution-Blocking Risk Status
|
|
38
40
|
|
package/package.json
CHANGED
package/packages/audit/index.mjs
CHANGED
|
@@ -30,9 +30,73 @@ const FORBIDDEN_KEYS = new Set([
|
|
|
30
30
|
"scopes", "labels"
|
|
31
31
|
]);
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// An audit STORE abstracts the exclusive "read-previous + persist" primitive so
|
|
34
|
+
// the SAME core-owned sha256 hash chain can sit on top of a file today and a
|
|
35
|
+
// shared store (e.g. Redis) in a future satellite. The contract is:
|
|
36
|
+
//
|
|
37
|
+
// async transaction(fn) — runs `fn` inside an EXCLUSIVE critical section that
|
|
38
|
+
// serializes concurrent appends. `fn` receives { readLastIntegrity, persist }
|
|
39
|
+
// where readLastIntegrity() -> the last record's auditIntegrity (or null) and
|
|
40
|
+
// persist(record) durably appends the built record. transaction() returns
|
|
41
|
+
// fn's return value.
|
|
42
|
+
// async ready() — OPTIONAL health/writability probe returning { ok, reason? };
|
|
43
|
+
// the sink falls back to { ok: true } when the store omits it.
|
|
44
|
+
//
|
|
45
|
+
// The store deliberately knows NOTHING about anchoring, sanitization, or the
|
|
46
|
+
// chain math — those stay core-owned in createAuditSink so a non-core store can
|
|
47
|
+
// never fork or weaken the chain.
|
|
48
|
+
|
|
49
|
+
// createFileAuditStore implements the store contract over the CURRENT JSONL
|
|
50
|
+
// mechanism: a `${path}.lock` exclusive section wrapping mkdir + the critical
|
|
51
|
+
// section, a tail-read for the previous integrity, and an appendFile persist.
|
|
52
|
+
// The on-disk bytes are identical to the pre-seam sink.
|
|
53
|
+
export function createFileAuditStore({ path }) {
|
|
34
54
|
if (!path) {
|
|
35
|
-
throw new Error("
|
|
55
|
+
throw new Error("file audit store requires path");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async transaction(fn) {
|
|
60
|
+
await mkdir(dirname(path), { recursive: true });
|
|
61
|
+
return withFileLock(`${path}.lock`, () => fn({
|
|
62
|
+
readLastIntegrity: () => readLastIntegrity(path),
|
|
63
|
+
persist: (record) => appendFile(path, `${JSON.stringify(record)}\n`, "utf8")
|
|
64
|
+
}));
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// WS4-A readiness probe: a CHEAP writability check used by /__haechi/ready.
|
|
68
|
+
// A security gateway that cannot append to its audit log is NOT ready
|
|
69
|
+
// (fail-closed), so this confirms the audit directory exists and is writable
|
|
70
|
+
// WITHOUT writing an event (no audit-chain side effect). It returns the bare
|
|
71
|
+
// boolean and an enum reason — never a path value or any payload/PII.
|
|
72
|
+
async ready() {
|
|
73
|
+
try {
|
|
74
|
+
const dir = dirname(path);
|
|
75
|
+
await mkdir(dir, { recursive: true });
|
|
76
|
+
await access(dir, fsConstants.W_OK);
|
|
77
|
+
// If the audit file already exists, confirm it is writable too.
|
|
78
|
+
try {
|
|
79
|
+
await access(path, fsConstants.W_OK);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.code !== "ENOENT") {
|
|
82
|
+
return { ok: false, reason: "audit_file_not_writable" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { ok: true };
|
|
86
|
+
} catch {
|
|
87
|
+
return { ok: false, reason: "audit_dir_not_writable" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// createAuditSink holds the SECURITY-CRITICAL, core-owned logic: writeQueue
|
|
94
|
+
// serialization, sanitizeAudit, the sha256 chain build, the anchor stream, and
|
|
95
|
+
// the capabilities object. The store only supplies the exclusive
|
|
96
|
+
// read-previous + persist primitive; anchor config never leaks into it.
|
|
97
|
+
export function createAuditSink({ store, anchor = null }) {
|
|
98
|
+
if (!store || typeof store.transaction !== "function") {
|
|
99
|
+
throw new Error("audit sink requires a store with a transaction(fn) method");
|
|
36
100
|
}
|
|
37
101
|
const anchorMode = anchor?.mode ?? "none";
|
|
38
102
|
const anchorPath = anchor?.path ?? null;
|
|
@@ -81,44 +145,41 @@ export function createJsonlAuditSink({ path, anchor = null }) {
|
|
|
81
145
|
integrity: anchorMode === "none" ? "sha256-hash-chain" : "sha256-hash-chain+anchor"
|
|
82
146
|
},
|
|
83
147
|
async record(event) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
148
|
+
// The writeQueue serializes record() calls on this sink, and the store's
|
|
149
|
+
// transaction() adds the exclusive critical section; together they keep
|
|
150
|
+
// the chain strictly sequential and never forked under concurrency.
|
|
151
|
+
const write = writeQueue.then(() => store.transaction(async ({ readLastIntegrity, persist }) => {
|
|
152
|
+
const record = buildIntegrityRecord(await readLastIntegrity(), sanitizeAudit(event));
|
|
153
|
+
await persist(record);
|
|
154
|
+
await writeAnchor(record);
|
|
155
|
+
return record;
|
|
156
|
+
}));
|
|
92
157
|
writeQueue = write.catch(() => {});
|
|
93
158
|
await write;
|
|
94
159
|
},
|
|
95
160
|
|
|
96
|
-
// WS4-A readiness probe: a CHEAP writability check used by /__haechi/ready.
|
|
97
|
-
// A security gateway that cannot append to its audit log is NOT ready
|
|
98
|
-
// (fail-closed), so this confirms the audit directory exists and is writable
|
|
99
|
-
// WITHOUT writing an event (no audit-chain side effect). It returns the bare
|
|
100
|
-
// boolean and an enum reason — never a path value or any payload/PII.
|
|
101
161
|
async ready() {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// If the audit file already exists, confirm it is writable too.
|
|
107
|
-
try {
|
|
108
|
-
await access(path, fsConstants.W_OK);
|
|
109
|
-
} catch (error) {
|
|
110
|
-
if (error.code !== "ENOENT") {
|
|
111
|
-
return { ok: false, reason: "audit_file_not_writable" };
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return { ok: true };
|
|
115
|
-
} catch {
|
|
116
|
-
return { ok: false, reason: "audit_dir_not_writable" };
|
|
162
|
+
// Delegate to the store's writability probe; a store that omits it is
|
|
163
|
+
// treated as ready (the chain math has no readiness side effect of its own).
|
|
164
|
+
if (typeof store.ready === "function") {
|
|
165
|
+
return store.ready();
|
|
117
166
|
}
|
|
167
|
+
return { ok: true };
|
|
118
168
|
}
|
|
119
169
|
};
|
|
120
170
|
}
|
|
121
171
|
|
|
172
|
+
// Thin back-compat wrapper: the original file-backed sink is now createAuditSink
|
|
173
|
+
// over createFileAuditStore. Its returned shape (id, version, capabilities,
|
|
174
|
+
// record, ready) and on-disk bytes are unchanged, so existing call sites
|
|
175
|
+
// (runtime.mjs injection, tests) keep working untouched.
|
|
176
|
+
export function createJsonlAuditSink({ path, anchor = null }) {
|
|
177
|
+
if (!path) {
|
|
178
|
+
throw new Error("JSONL audit sink requires path");
|
|
179
|
+
}
|
|
180
|
+
return createAuditSink({ store: createFileAuditStore({ path }), anchor });
|
|
181
|
+
}
|
|
182
|
+
|
|
122
183
|
export async function readAuditSummary(path) {
|
|
123
184
|
const summary = {
|
|
124
185
|
events: 0,
|
|
@@ -272,8 +333,13 @@ async function readAnchors(anchorPath) {
|
|
|
272
333
|
return { bySequence, lastSequence };
|
|
273
334
|
}
|
|
274
335
|
|
|
275
|
-
|
|
276
|
-
|
|
336
|
+
// PURE chain math: given the previous record's auditIntegrity (or null) and a
|
|
337
|
+
// sanitized event, deterministically computes the next chained record. No fs,
|
|
338
|
+
// no IO — the store supplies `previousIntegrity` (via its read-previous
|
|
339
|
+
// primitive) so the SAME computation backs a file or a shared store. Exported
|
|
340
|
+
// for store/satellite tests.
|
|
341
|
+
export function buildIntegrityRecord(previousIntegrity, event) {
|
|
342
|
+
const previous = previousIntegrity ?? null;
|
|
277
343
|
const sequence = previous ? previous.sequence + 1 : 1;
|
|
278
344
|
const unsigned = {
|
|
279
345
|
...event,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createPrivateKey, generateKeyPairSync } from "node:crypto";
|
|
3
5
|
import { readAuditSummary, verifyAuditChain } from "../../audit/index.mjs";
|
|
4
6
|
import { DEFAULT_PROXY_PORT, HAECHI_VERSION, createHaechiProxy } from "../../proxy/index.mjs";
|
|
5
7
|
import { signPolicyBundleFile, verifyPolicyBundleFile } from "../../policy-bundle/index.mjs";
|
|
6
|
-
import { validatePluginManifestFile } from "../../plugin/index.mjs";
|
|
8
|
+
import { PluginLoadError, signPluginManifest, validatePluginManifestFile, verifySignedPlugin } from "../../plugin/index.mjs";
|
|
7
9
|
import { runMcpStdioFilter, wrapMcpChild } from "../../mcp-stdio/index.mjs";
|
|
8
10
|
import { addToken, listTokens, revokeToken } from "../../auth/index.mjs";
|
|
9
11
|
import { createLocalCryptoProvider } from "../../crypto/index.mjs";
|
|
@@ -51,6 +53,15 @@ try {
|
|
|
51
53
|
case "plugin-validate":
|
|
52
54
|
await pluginValidateCommand(argv);
|
|
53
55
|
break;
|
|
56
|
+
case "plugin-keygen":
|
|
57
|
+
await pluginKeygenCommand(argv);
|
|
58
|
+
break;
|
|
59
|
+
case "plugin-sign":
|
|
60
|
+
await pluginSignCommand(argv);
|
|
61
|
+
break;
|
|
62
|
+
case "plugin-verify":
|
|
63
|
+
await pluginVerifyCommand(argv);
|
|
64
|
+
break;
|
|
54
65
|
case "mcp-stdio":
|
|
55
66
|
await mcpStdioCommand(argv);
|
|
56
67
|
break;
|
|
@@ -472,6 +483,251 @@ async function pluginValidateCommand(argv) {
|
|
|
472
483
|
}
|
|
473
484
|
}
|
|
474
485
|
|
|
486
|
+
// plugin-keygen — generate an Ed25519 keypair for signing plugin envelopes. The
|
|
487
|
+
// PRIVATE key is written PKCS8 PEM at 0600 (operator-readable only); the PUBLIC
|
|
488
|
+
// key is written SPKI PEM (this is the trust anchor an operator pastes into
|
|
489
|
+
// auth.plugin.trustAnchors). The JSON output carries ONLY non-secret fields plus
|
|
490
|
+
// the PATH to the private key — never the private key material itself.
|
|
491
|
+
async function pluginKeygenCommand(argv) {
|
|
492
|
+
const options = parseOptions(argv);
|
|
493
|
+
const keyId = typeof options["key-id"] === "string" ? options["key-id"] : "haechi-plugin-signer";
|
|
494
|
+
const outDir = typeof options["out-dir"] === "string" ? options["out-dir"] : ".";
|
|
495
|
+
|
|
496
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
497
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
|
498
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
|
499
|
+
|
|
500
|
+
const privateKeyPath = join(outDir, `${keyId}.key`);
|
|
501
|
+
const publicKeyPath = join(outDir, `${keyId}.pub`);
|
|
502
|
+
|
|
503
|
+
await mkdir(outDir, { recursive: true });
|
|
504
|
+
// Restrictive mode on the private key: written 0600 so it is not group/world
|
|
505
|
+
// readable. (mkdir above is best-effort for "." which always exists.)
|
|
506
|
+
await writeFile(privateKeyPath, privateKeyPem, { mode: 0o600 });
|
|
507
|
+
await writeFile(publicKeyPath, publicKeyPem);
|
|
508
|
+
|
|
509
|
+
writeJson({
|
|
510
|
+
ok: true,
|
|
511
|
+
command: "plugin-keygen",
|
|
512
|
+
keyId,
|
|
513
|
+
privateKeyPath,
|
|
514
|
+
publicKeyPath,
|
|
515
|
+
publicKeyPem
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// plugin-sign — Ed25519-sign a plugin envelope. The entry bytes are read as RAW
|
|
520
|
+
// bytes (no transcoding) so entrySha256 binds the exact on-disk plugin source.
|
|
521
|
+
// The private key is read from a FILE (never from argv — a key on the command
|
|
522
|
+
// line leaks into process args / shell history). Output is the signed envelope
|
|
523
|
+
// JSON; the JSON status print never includes private material.
|
|
524
|
+
async function pluginSignCommand(argv) {
|
|
525
|
+
const [entryPath, ...rest] = argv;
|
|
526
|
+
if (!entryPath || entryPath.startsWith("--")) {
|
|
527
|
+
throw new Error("plugin-sign requires an entry file path");
|
|
528
|
+
}
|
|
529
|
+
const options = parseOptions(rest);
|
|
530
|
+
|
|
531
|
+
const required = {
|
|
532
|
+
key: "--key <private-key.pem>",
|
|
533
|
+
"signer-key-id": "--signer-key-id <id>",
|
|
534
|
+
"plugin-id": "--plugin-id <id>",
|
|
535
|
+
kind: "--kind <kind>",
|
|
536
|
+
"plugin-version": "--plugin-version <v>",
|
|
537
|
+
"core-range": "--core-range <range>"
|
|
538
|
+
};
|
|
539
|
+
for (const [flag, usage] of Object.entries(required)) {
|
|
540
|
+
if (typeof options[flag] !== "string" || options[flag].length === 0) {
|
|
541
|
+
throw new Error(`plugin-sign requires ${usage}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Read the EXACT entry bytes (Buffer, no utf8 transcoding) so the signed
|
|
546
|
+
// entrySha256 binds the real source.
|
|
547
|
+
const entryBytes = await readFile(entryPath);
|
|
548
|
+
// Read the private key from the file, not from argv.
|
|
549
|
+
const privateKey = createPrivateKey(await readFile(options.key, "utf8"));
|
|
550
|
+
|
|
551
|
+
const capabilities = await parseCapabilitiesOption(options.capabilities);
|
|
552
|
+
const notBefore = parseOptionalEpochMs(options["not-before"], "--not-before");
|
|
553
|
+
const notAfter = parseOptionalEpochMs(options["not-after"], "--not-after");
|
|
554
|
+
|
|
555
|
+
const pluginId = options["plugin-id"];
|
|
556
|
+
const signed = signPluginManifest(
|
|
557
|
+
{
|
|
558
|
+
pluginId,
|
|
559
|
+
kind: options.kind,
|
|
560
|
+
version: options["plugin-version"],
|
|
561
|
+
capabilities,
|
|
562
|
+
coreVersionRange: options["core-range"],
|
|
563
|
+
entryBytes,
|
|
564
|
+
notBefore,
|
|
565
|
+
notAfter
|
|
566
|
+
},
|
|
567
|
+
privateKey,
|
|
568
|
+
options["signer-key-id"]
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
const outPath = typeof options.out === "string" ? options.out : `${pluginId}.signed.json`;
|
|
572
|
+
await writeFile(outPath, `${JSON.stringify(signed, null, 2)}\n`);
|
|
573
|
+
|
|
574
|
+
writeJson({
|
|
575
|
+
ok: true,
|
|
576
|
+
command: "plugin-sign",
|
|
577
|
+
outPath,
|
|
578
|
+
pluginId,
|
|
579
|
+
signerKeyId: signed.signerKeyId,
|
|
580
|
+
entrySha256: signed.payload.entrySha256,
|
|
581
|
+
kind: signed.payload.kind,
|
|
582
|
+
version: signed.payload.version
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// plugin-verify — verify a signed plugin envelope against the exact entry bytes
|
|
587
|
+
// and operator trust anchors. Anchors come from EITHER an explicit --anchor PEM
|
|
588
|
+
// (+ --anchor-key-id, defaulting to the envelope's signerKeyId) OR a --config
|
|
589
|
+
// file's auth.plugin.trustAnchors. On success prints valid:true; on a
|
|
590
|
+
// PluginLoadError it FAILS CLOSED — the error propagates to main()'s catch, the
|
|
591
|
+
// reason code is printed to stderr, and the process exits non-zero (the gate
|
|
592
|
+
// signal). Never prints private material.
|
|
593
|
+
async function pluginVerifyCommand(argv) {
|
|
594
|
+
const [signedPath, ...rest] = argv;
|
|
595
|
+
if (!signedPath || signedPath.startsWith("--")) {
|
|
596
|
+
throw new Error("plugin-verify requires a signed envelope JSON file path");
|
|
597
|
+
}
|
|
598
|
+
const options = parseOptions(rest);
|
|
599
|
+
if (typeof options.entry !== "string" || options.entry.length === 0) {
|
|
600
|
+
throw new Error("plugin-verify requires --entry <entry-file>");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const signed = JSON.parse(await readFile(signedPath, "utf8"));
|
|
604
|
+
const entryBytes = await readFile(options.entry);
|
|
605
|
+
|
|
606
|
+
const trustAnchors = await resolvePluginTrustAnchors(options, signed);
|
|
607
|
+
|
|
608
|
+
const coreVersion = typeof options["core-version"] === "string" ? options["core-version"] : null;
|
|
609
|
+
const pin = typeof options.pin === "string" ? { entrySha256: options.pin } : null;
|
|
610
|
+
|
|
611
|
+
// --allow-capability <name> (repeatable) mirrors the OPERATOR capability
|
|
612
|
+
// allowlist that createRuntime passes at load time. It is REQUIRED to verify an
|
|
613
|
+
// authProvider envelope (core mandates such a plugin declare readsCredentials,
|
|
614
|
+
// which is not allowlisted by default) — without it, plugin-verify can only
|
|
615
|
+
// confirm a no-capability plugin. A bare flag (no value) is ignored.
|
|
616
|
+
const rawAllow = options["allow-capability"];
|
|
617
|
+
const allowCapabilities = (Array.isArray(rawAllow) ? rawAllow : [rawAllow])
|
|
618
|
+
.filter((value) => typeof value === "string" && value.length > 0);
|
|
619
|
+
|
|
620
|
+
// verifySignedPlugin throws a PluginLoadError on any refusal. We surface the
|
|
621
|
+
// stable .reason CODE (the gate signal) in the error message and re-throw so
|
|
622
|
+
// main()'s catch prints it and sets a non-zero exit code. A non-zero exit +
|
|
623
|
+
// the reason code is what a caller branches on (never a free-text message).
|
|
624
|
+
let payload;
|
|
625
|
+
try {
|
|
626
|
+
payload = verifySignedPlugin({
|
|
627
|
+
signed,
|
|
628
|
+
entryBytes,
|
|
629
|
+
trustAnchors,
|
|
630
|
+
coreVersion,
|
|
631
|
+
pin,
|
|
632
|
+
allowCapabilities
|
|
633
|
+
});
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (error instanceof PluginLoadError) {
|
|
636
|
+
throw new Error(`plugin-verify refused: ${error.reason} (${error.message})`);
|
|
637
|
+
}
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
writeJson({
|
|
642
|
+
ok: true,
|
|
643
|
+
command: "plugin-verify",
|
|
644
|
+
valid: true,
|
|
645
|
+
pluginId: payload.pluginId,
|
|
646
|
+
signerKeyId: signed.signerKeyId,
|
|
647
|
+
entrySha256: payload.entrySha256
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Resolve plugin trust anchors for plugin-verify. Precedence: an explicit
|
|
652
|
+
// --anchor PEM file (keyed by --anchor-key-id, defaulting to the envelope's
|
|
653
|
+
// signerKeyId) wins; otherwise --config supplies auth.plugin.trustAnchors. The
|
|
654
|
+
// config is read as RAW JSON (not normalizeConfig) so verifying an envelope does
|
|
655
|
+
// not require a full auth.provider:"plugin" config — only the anchors matter.
|
|
656
|
+
async function resolvePluginTrustAnchors(options, signed) {
|
|
657
|
+
if (typeof options.anchor === "string" && options.anchor.length > 0) {
|
|
658
|
+
const publicKeyPem = await readFile(options.anchor, "utf8");
|
|
659
|
+
const keyId = typeof options["anchor-key-id"] === "string" && options["anchor-key-id"].length > 0
|
|
660
|
+
? options["anchor-key-id"]
|
|
661
|
+
: signed?.signerKeyId;
|
|
662
|
+
if (typeof keyId !== "string" || keyId.length === 0) {
|
|
663
|
+
throw new Error("plugin-verify --anchor requires --anchor-key-id (or a signerKeyId in the envelope)");
|
|
664
|
+
}
|
|
665
|
+
return { [keyId]: publicKeyPem };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (typeof options.config === "string" && options.config.length > 0) {
|
|
669
|
+
const raw = JSON.parse(await readFile(options.config, "utf8"));
|
|
670
|
+
const anchors = raw?.auth?.plugin?.trustAnchors;
|
|
671
|
+
if (Array.isArray(anchors)) {
|
|
672
|
+
const map = {};
|
|
673
|
+
for (const anchor of anchors) {
|
|
674
|
+
if (!anchor || typeof anchor !== "object" || typeof anchor.keyId !== "string" || anchor.publicKey == null) {
|
|
675
|
+
throw new Error("each auth.plugin.trustAnchors entry must be { keyId, publicKey }");
|
|
676
|
+
}
|
|
677
|
+
map[anchor.keyId] = anchor.publicKey;
|
|
678
|
+
}
|
|
679
|
+
if (Object.keys(map).length === 0) {
|
|
680
|
+
throw new Error("auth.plugin.trustAnchors in --config is empty");
|
|
681
|
+
}
|
|
682
|
+
return map;
|
|
683
|
+
}
|
|
684
|
+
if (anchors && typeof anchors === "object") {
|
|
685
|
+
if (Object.keys(anchors).length === 0) {
|
|
686
|
+
throw new Error("auth.plugin.trustAnchors in --config is empty");
|
|
687
|
+
}
|
|
688
|
+
return anchors;
|
|
689
|
+
}
|
|
690
|
+
throw new Error("--config has no auth.plugin.trustAnchors to verify against");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
throw new Error("plugin-verify requires either --anchor <public-key.pem> or --config <haechi.config.json>");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Parse the --capabilities option: inline JSON, or @file pointing at a JSON
|
|
697
|
+
// file. Defaults to {} (an empty capability set). Must resolve to a plain
|
|
698
|
+
// object; anything else fails closed.
|
|
699
|
+
async function parseCapabilitiesOption(value) {
|
|
700
|
+
if (value === undefined || value === true) {
|
|
701
|
+
return {};
|
|
702
|
+
}
|
|
703
|
+
if (typeof value !== "string") {
|
|
704
|
+
throw new Error("--capabilities must be inline JSON or @file");
|
|
705
|
+
}
|
|
706
|
+
const text = value.startsWith("@") ? await readFile(value.slice(1), "utf8") : value;
|
|
707
|
+
let parsed;
|
|
708
|
+
try {
|
|
709
|
+
parsed = JSON.parse(text);
|
|
710
|
+
} catch (error) {
|
|
711
|
+
throw new Error(`--capabilities is not valid JSON: ${error.message}`);
|
|
712
|
+
}
|
|
713
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
714
|
+
throw new Error("--capabilities must be a JSON object");
|
|
715
|
+
}
|
|
716
|
+
return parsed;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Parse an optional epoch-ms flag value. Absent -> undefined (the signer treats
|
|
720
|
+
// it as null/unbounded). A present value must be an integer string.
|
|
721
|
+
function parseOptionalEpochMs(value, flag) {
|
|
722
|
+
if (value === undefined) {
|
|
723
|
+
return undefined;
|
|
724
|
+
}
|
|
725
|
+
if (typeof value !== "string" || !/^-?\d+$/.test(value.trim())) {
|
|
726
|
+
throw new Error(`${flag} must be an integer epoch-ms value`);
|
|
727
|
+
}
|
|
728
|
+
return Number(value);
|
|
729
|
+
}
|
|
730
|
+
|
|
475
731
|
async function authCommand(argv) {
|
|
476
732
|
const [sub, ...rest] = argv;
|
|
477
733
|
const options = parseOptions(rest);
|
|
@@ -759,6 +1015,21 @@ const COMMAND_HELP = {
|
|
|
759
1015
|
usage: "haechi plugin-validate <plugin-manifest.json>",
|
|
760
1016
|
summary: "Validate a plugin manifest (manifest-only; dynamic runtime is rejected)."
|
|
761
1017
|
},
|
|
1018
|
+
"plugin-keygen": {
|
|
1019
|
+
usage: "haechi plugin-keygen [--key-id haechi-plugin-signer] [--out-dir .]",
|
|
1020
|
+
summary: "Generate an Ed25519 plugin-signing keypair.",
|
|
1021
|
+
detail: "Writes the PKCS8-PEM private key to <out-dir>/<keyId>.key (0600) and the SPKI-PEM public key to <out-dir>/<keyId>.pub. The public key is the trust anchor an operator pastes into auth.plugin.trustAnchors. The private key is never printed — only its path."
|
|
1022
|
+
},
|
|
1023
|
+
"plugin-sign": {
|
|
1024
|
+
usage: "haechi plugin-sign <entry-file> --key <private-key.pem> --signer-key-id <id> --plugin-id <id> --kind <kind> --plugin-version <v> --core-range <range> [--capabilities <json|@file>] [--not-before <ms>] [--not-after <ms>] [--out <signed.json>]",
|
|
1025
|
+
summary: "Ed25519-sign a plugin envelope binding the exact entry bytes.",
|
|
1026
|
+
detail: "Reads the entry file as raw bytes (entrySha256 binds the real source) and the private key from the --key FILE (never argv). Writes the signed envelope to --out (default <plugin-id>.signed.json). Capabilities default to {}; provide inline JSON or @file. The private key is never printed."
|
|
1027
|
+
},
|
|
1028
|
+
"plugin-verify": {
|
|
1029
|
+
usage: "haechi plugin-verify <signed.json> --entry <entry-file> [--anchor <public-key.pem> --anchor-key-id <id>] [--config haechi.config.json] [--core-version <v>] [--pin <entrySha256>] [--allow-capability <name>]...",
|
|
1030
|
+
summary: "Verify a signed plugin envelope; fail closed on any refusal.",
|
|
1031
|
+
detail: "Resolves trust anchors from --anchor (with --anchor-key-id, default the envelope signerKeyId) or from --config auth.plugin.trustAnchors. Pass --allow-capability <name> (repeatable) to allowlist each declared capability — REQUIRED to verify an authProvider envelope (it must declare readsCredentials, which is not allowlisted by default). On success prints valid:true; on any refusal it exits non-zero with the PluginLoadError reason (the gate signal)."
|
|
1032
|
+
},
|
|
762
1033
|
"mcp-stdio": {
|
|
763
1034
|
usage: "haechi mcp-stdio [--config haechi.config.json]",
|
|
764
1035
|
summary: "Filter MCP JSON-RPC traffic on stdin/stdout (one direction)."
|
|
@@ -790,7 +1061,8 @@ function printHelp(topic) {
|
|
|
790
1061
|
"init", "protect", "report", "status", "audit-verify", "proxy",
|
|
791
1062
|
"policy-sign", "policy-verify",
|
|
792
1063
|
"token-reveal", "token-purge", "token-export",
|
|
793
|
-
"plugin-validate", "
|
|
1064
|
+
"plugin-validate", "plugin-keygen", "plugin-sign", "plugin-verify",
|
|
1065
|
+
"mcp-stdio", "mcp-wrap", "auth", "config"
|
|
794
1066
|
];
|
|
795
1067
|
const lines = order.map((name) => ` ${name.padEnd(16)}${COMMAND_HELP[name].summary}`);
|
|
796
1068
|
console.log(`Haechi — self-hosted AI context enforcement
|