haechi 0.3.2

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.
Files changed (54) hide show
  1. package/LICENSE +154 -0
  2. package/README.md +102 -0
  3. package/SECURITY.md +31 -0
  4. package/docs/README.md +35 -0
  5. package/docs/current/api-stability.ko.md +48 -0
  6. package/docs/current/api-stability.md +48 -0
  7. package/docs/current/expert-gap-review-ai-llm-mcp-encryption.ko.md +107 -0
  8. package/docs/current/expert-gap-review-ai-llm-mcp-encryption.md +107 -0
  9. package/docs/current/global-privacy-compliance-review.ko.md +110 -0
  10. package/docs/current/global-privacy-compliance-review.md +110 -0
  11. package/docs/current/initial-plan-ai-llm-mcp-encryption.ko.md +214 -0
  12. package/docs/current/initial-plan-ai-llm-mcp-encryption.md +214 -0
  13. package/docs/current/mvp-0.1-implementation-scope.ko.md +79 -0
  14. package/docs/current/mvp-0.1-implementation-scope.md +79 -0
  15. package/docs/current/open-source-modular-architecture.ko.md +387 -0
  16. package/docs/current/open-source-modular-architecture.md +387 -0
  17. package/docs/current/prd-ai-llm-mcp-encryption.ko.md +260 -0
  18. package/docs/current/prd-ai-llm-mcp-encryption.md +262 -0
  19. package/docs/current/privacy-filtering-policy-draft.ko.md +307 -0
  20. package/docs/current/privacy-filtering-policy-draft.md +307 -0
  21. package/docs/current/release-0.2-implementation-scope.ko.md +46 -0
  22. package/docs/current/release-0.2-implementation-scope.md +46 -0
  23. package/docs/current/release-0.3-implementation-scope.ko.md +86 -0
  24. package/docs/current/release-0.3-implementation-scope.md +86 -0
  25. package/docs/current/release-0.3.2-hardening-scope.ko.md +64 -0
  26. package/docs/current/release-0.3.2-hardening-scope.md +64 -0
  27. package/docs/current/release-0.4-implementation-scope.ko.md +121 -0
  28. package/docs/current/release-0.4-implementation-scope.md +121 -0
  29. package/docs/current/release-process.ko.md +48 -0
  30. package/docs/current/release-process.md +48 -0
  31. package/docs/current/risk-register-release-gate.ko.md +154 -0
  32. package/docs/current/risk-register-release-gate.md +154 -0
  33. package/docs/current/shared-responsibility.ko.md +38 -0
  34. package/docs/current/shared-responsibility.md +38 -0
  35. package/docs/current/threat-model.ko.md +68 -0
  36. package/docs/current/threat-model.md +68 -0
  37. package/examples/llm-prompt-filtering/input.json +13 -0
  38. package/examples/plugins/custom-filter.plugin.json +29 -0
  39. package/haechi.config.example.json +70 -0
  40. package/package.json +74 -0
  41. package/packages/audit/index.mjs +262 -0
  42. package/packages/cli/bin/haechi.mjs +341 -0
  43. package/packages/cli/runtime.mjs +287 -0
  44. package/packages/core/index.mjs +309 -0
  45. package/packages/crypto/index.mjs +142 -0
  46. package/packages/filter/index.mjs +189 -0
  47. package/packages/mcp-stdio/index.mjs +105 -0
  48. package/packages/plugin/index.mjs +83 -0
  49. package/packages/policy/index.mjs +165 -0
  50. package/packages/policy-bundle/index.mjs +91 -0
  51. package/packages/privacy-profiles/index.mjs +92 -0
  52. package/packages/protocol-adapters/index.mjs +111 -0
  53. package/packages/proxy/index.mjs +534 -0
  54. package/packages/token-vault/index.mjs +262 -0
@@ -0,0 +1,262 @@
1
+ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+
6
+ export function createLocalTokenVault({ path, cryptoProvider, revealPolicy = "disabled", retentionDays = 30, auditSink = null }) {
7
+ if (!path) {
8
+ throw new Error("Local token vault requires path");
9
+ }
10
+ if (!cryptoProvider) {
11
+ throw new Error("Local token vault requires cryptoProvider");
12
+ }
13
+
14
+ let mutationQueue = Promise.resolve();
15
+ async function enqueueMutation(operation) {
16
+ const mutation = mutationQueue.then(async () => {
17
+ await mkdir(dirname(path), { recursive: true });
18
+ return withFileLock(`${path}.lock`, operation);
19
+ });
20
+ mutationQueue = mutation.catch(() => {});
21
+ return mutation;
22
+ }
23
+
24
+ // Reveal/purge governance events must be auditable. Events carry token ids
25
+ // and decision metadata only — never plaintext values.
26
+ async function recordVaultEvent({ operation, decision, token = null, tokenType = null, reason = null, count = null }) {
27
+ if (typeof auditSink?.record !== "function") {
28
+ return;
29
+ }
30
+ await auditSink.record({
31
+ id: randomUUID(),
32
+ timestamp: new Date().toISOString(),
33
+ protocol: "token-vault",
34
+ operation,
35
+ mode: "n/a",
36
+ enforced: true,
37
+ blocked: decision.endsWith("_denied"),
38
+ decision,
39
+ reason,
40
+ token,
41
+ tokenType,
42
+ count,
43
+ revealPolicy,
44
+ summary: {
45
+ detectionCount: 0,
46
+ byType: {},
47
+ byAction: {
48
+ [decision]: 1
49
+ }
50
+ }
51
+ });
52
+ }
53
+
54
+ return {
55
+ id: "haechi.token-vault.local",
56
+ version: "0.2.0",
57
+ capabilities: {
58
+ readsPlaintext: true,
59
+ storesPayload: true,
60
+ storesPlaintext: false,
61
+ networkEgress: false,
62
+ revealPolicy
63
+ },
64
+ async tokenize({ plaintext, type, context = {}, metadata = {} }) {
65
+ return enqueueMutation(async () => {
66
+ const vault = await readVault(path);
67
+ pruneExpiredTokens(vault);
68
+ const token = `tok_${type}_${shortHash(`${plaintext}:${randomBytes(16).toString("hex")}`)}`;
69
+ const createdAt = new Date();
70
+ const aad = {
71
+ purpose: "token-vault",
72
+ token,
73
+ type,
74
+ context
75
+ };
76
+ vault.tokens[token] = {
77
+ type,
78
+ createdAt: createdAt.toISOString(),
79
+ expiresAt: addDays(createdAt, retentionDays).toISOString(),
80
+ metadata: sanitizeMetadata(metadata),
81
+ envelope: await cryptoProvider.encrypt({ plaintext, aad }),
82
+ aad
83
+ };
84
+ await writeVault(path, vault);
85
+ return { token, type };
86
+ });
87
+ },
88
+ async reveal({ token, context = null }) {
89
+ if (revealPolicy === "disabled") {
90
+ await recordVaultEvent({
91
+ operation: "token-vault:reveal",
92
+ decision: "reveal_denied",
93
+ token,
94
+ reason: "reveal_policy_disabled"
95
+ });
96
+ throw new Error("Token reveal is disabled by tokenVault.revealPolicy");
97
+ }
98
+ try {
99
+ const vault = await readVault(path);
100
+ const record = vault.tokens[token];
101
+ if (!record) {
102
+ throw new Error(`Unknown token: ${token}`);
103
+ }
104
+ if (record.expiresAt && Date.parse(record.expiresAt) < Date.now()) {
105
+ throw new Error(`Token expired: ${token}`);
106
+ }
107
+ const aad = context ? { ...record.aad, context } : record.aad;
108
+ const plaintext = await cryptoProvider.decrypt({ envelope: record.envelope, aad });
109
+ await recordVaultEvent({
110
+ operation: "token-vault:reveal",
111
+ decision: "reveal_allowed",
112
+ token,
113
+ tokenType: record.type
114
+ });
115
+ return {
116
+ token,
117
+ type: record.type,
118
+ plaintext
119
+ };
120
+ } catch (error) {
121
+ await recordVaultEvent({
122
+ operation: "token-vault:reveal",
123
+ decision: "reveal_failed",
124
+ token,
125
+ reason: error.message
126
+ });
127
+ throw error;
128
+ }
129
+ },
130
+ async purge({ token }) {
131
+ return enqueueMutation(async () => {
132
+ const vault = await readVault(path);
133
+ pruneExpiredTokens(vault);
134
+ const existed = Boolean(vault.tokens[token]);
135
+ delete vault.tokens[token];
136
+ await writeVault(path, vault);
137
+ await recordVaultEvent({
138
+ operation: "token-vault:purge",
139
+ decision: "purge",
140
+ token
141
+ });
142
+ return { token, purged: existed, purgedAt: new Date().toISOString() };
143
+ });
144
+ },
145
+ async purgeExpired() {
146
+ return enqueueMutation(async () => {
147
+ const vault = await readVault(path);
148
+ const purged = pruneExpiredTokens(vault);
149
+ await writeVault(path, vault);
150
+ await recordVaultEvent({
151
+ operation: "token-vault:purge-expired",
152
+ decision: "purge_expired",
153
+ count: purged
154
+ });
155
+ return { purged, purgedAt: new Date().toISOString() };
156
+ });
157
+ },
158
+ async exportMetadata({ type = null } = {}) {
159
+ const vault = await readVault(path);
160
+ return Object.entries(vault.tokens)
161
+ .filter(([, record]) => !type || record.type === type)
162
+ .map(([token, record]) => ({
163
+ token,
164
+ type: record.type,
165
+ createdAt: record.createdAt,
166
+ expiresAt: record.expiresAt,
167
+ metadata: sanitizeMetadata(record.metadata ?? {})
168
+ }));
169
+ }
170
+ };
171
+ }
172
+
173
+ export async function readVault(path) {
174
+ try {
175
+ return JSON.parse(await readFile(path, "utf8"));
176
+ } catch (error) {
177
+ if (error.code !== "ENOENT") {
178
+ throw error;
179
+ }
180
+ return {
181
+ version: 1,
182
+ createdAt: new Date().toISOString(),
183
+ tokens: {}
184
+ };
185
+ }
186
+ }
187
+
188
+ async function writeVault(path, vault) {
189
+ await mkdir(dirname(path), { recursive: true });
190
+ const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
191
+ await writeFile(tempPath, `${JSON.stringify(vault, null, 2)}\n`, { mode: 0o600 });
192
+ await rename(tempPath, path);
193
+ }
194
+
195
+ function pruneExpiredTokens(vault, now = Date.now()) {
196
+ let purged = 0;
197
+ for (const [token, record] of Object.entries(vault.tokens)) {
198
+ if (record.expiresAt && Date.parse(record.expiresAt) < now) {
199
+ delete vault.tokens[token];
200
+ purged += 1;
201
+ }
202
+ }
203
+ return purged;
204
+ }
205
+
206
+ function sanitizeMetadata(metadata) {
207
+ return Object.fromEntries(Object.entries(metadata).filter(([key]) => !["value", "plaintext", "payload"].includes(key)));
208
+ }
209
+
210
+ function shortHash(value) {
211
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
212
+ }
213
+
214
+ function addDays(date, days) {
215
+ return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
216
+ }
217
+
218
+ async function withFileLock(lockPath, operation) {
219
+ const handle = await acquireLock(lockPath);
220
+ try {
221
+ return await operation();
222
+ } finally {
223
+ await handle.close();
224
+ await unlink(lockPath).catch((error) => {
225
+ if (error.code !== "ENOENT") {
226
+ throw error;
227
+ }
228
+ });
229
+ }
230
+ }
231
+
232
+ const STALE_LOCK_MS = 30000;
233
+
234
+ async function acquireLock(lockPath) {
235
+ const deadline = Date.now() + 5000;
236
+ while (true) {
237
+ try {
238
+ return await open(lockPath, "wx", 0o600);
239
+ } catch (error) {
240
+ if (error.code !== "EEXIST") {
241
+ throw error;
242
+ }
243
+ if (await isStaleLock(lockPath)) {
244
+ await unlink(lockPath).catch(() => {});
245
+ continue;
246
+ }
247
+ if (Date.now() > deadline) {
248
+ throw new Error(`Timed out acquiring token vault lock: ${lockPath}`);
249
+ }
250
+ await delay(10);
251
+ }
252
+ }
253
+ }
254
+
255
+ async function isStaleLock(lockPath) {
256
+ try {
257
+ const info = await stat(lockPath);
258
+ return Date.now() - info.mtimeMs > STALE_LOCK_MS;
259
+ } catch {
260
+ return false;
261
+ }
262
+ }