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.
- package/LICENSE +154 -0
- package/README.md +102 -0
- package/SECURITY.md +31 -0
- package/docs/README.md +35 -0
- package/docs/current/api-stability.ko.md +48 -0
- package/docs/current/api-stability.md +48 -0
- package/docs/current/expert-gap-review-ai-llm-mcp-encryption.ko.md +107 -0
- package/docs/current/expert-gap-review-ai-llm-mcp-encryption.md +107 -0
- package/docs/current/global-privacy-compliance-review.ko.md +110 -0
- package/docs/current/global-privacy-compliance-review.md +110 -0
- package/docs/current/initial-plan-ai-llm-mcp-encryption.ko.md +214 -0
- package/docs/current/initial-plan-ai-llm-mcp-encryption.md +214 -0
- package/docs/current/mvp-0.1-implementation-scope.ko.md +79 -0
- package/docs/current/mvp-0.1-implementation-scope.md +79 -0
- package/docs/current/open-source-modular-architecture.ko.md +387 -0
- package/docs/current/open-source-modular-architecture.md +387 -0
- package/docs/current/prd-ai-llm-mcp-encryption.ko.md +260 -0
- package/docs/current/prd-ai-llm-mcp-encryption.md +262 -0
- package/docs/current/privacy-filtering-policy-draft.ko.md +307 -0
- package/docs/current/privacy-filtering-policy-draft.md +307 -0
- package/docs/current/release-0.2-implementation-scope.ko.md +46 -0
- package/docs/current/release-0.2-implementation-scope.md +46 -0
- package/docs/current/release-0.3-implementation-scope.ko.md +86 -0
- package/docs/current/release-0.3-implementation-scope.md +86 -0
- package/docs/current/release-0.3.2-hardening-scope.ko.md +64 -0
- package/docs/current/release-0.3.2-hardening-scope.md +64 -0
- package/docs/current/release-0.4-implementation-scope.ko.md +121 -0
- package/docs/current/release-0.4-implementation-scope.md +121 -0
- package/docs/current/release-process.ko.md +48 -0
- package/docs/current/release-process.md +48 -0
- package/docs/current/risk-register-release-gate.ko.md +154 -0
- package/docs/current/risk-register-release-gate.md +154 -0
- package/docs/current/shared-responsibility.ko.md +38 -0
- package/docs/current/shared-responsibility.md +38 -0
- package/docs/current/threat-model.ko.md +68 -0
- package/docs/current/threat-model.md +68 -0
- package/examples/llm-prompt-filtering/input.json +13 -0
- package/examples/plugins/custom-filter.plugin.json +29 -0
- package/haechi.config.example.json +70 -0
- package/package.json +74 -0
- package/packages/audit/index.mjs +262 -0
- package/packages/cli/bin/haechi.mjs +341 -0
- package/packages/cli/runtime.mjs +287 -0
- package/packages/core/index.mjs +309 -0
- package/packages/crypto/index.mjs +142 -0
- package/packages/filter/index.mjs +189 -0
- package/packages/mcp-stdio/index.mjs +105 -0
- package/packages/plugin/index.mjs +83 -0
- package/packages/policy/index.mjs +165 -0
- package/packages/policy-bundle/index.mjs +91 -0
- package/packages/privacy-profiles/index.mjs +92 -0
- package/packages/protocol-adapters/index.mjs +111 -0
- package/packages/proxy/index.mjs +534 -0
- 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
|
+
}
|