haechi 0.3.2 → 0.4.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.md +116 -0
- package/docs/current/api-stability.ko.md +10 -4
- package/docs/current/api-stability.md +10 -4
- package/docs/current/release-0.3.2-hardening-scope.ko.md +2 -1
- package/docs/current/release-0.3.2-hardening-scope.md +2 -1
- package/docs/current/release-0.4-implementation-scope.ko.md +2 -1
- package/docs/current/release-0.4-implementation-scope.md +2 -1
- package/docs/current/release-process.ko.md +14 -4
- package/docs/current/release-process.md +14 -4
- package/docs/current/risk-register-release-gate.ko.md +10 -10
- package/docs/current/risk-register-release-gate.md +11 -11
- package/docs/current/threat-model.ko.md +3 -1
- package/docs/current/threat-model.md +3 -1
- package/haechi.config.example.json +4 -1
- package/package.json +6 -1
- package/packages/audit/index.mjs +3 -1
- package/packages/cli/bin/haechi.mjs +151 -3
- package/packages/cli/runtime.mjs +18 -1
- package/packages/core/index.mjs +18 -9
- package/packages/crypto/index.mjs +13 -1
- package/packages/filter/index.mjs +52 -3
- package/packages/mcp-stdio/index.mjs +103 -22
- package/packages/policy/index.mjs +6 -0
- package/packages/proxy/index.mjs +41 -3
- package/packages/token-vault/index.mjs +70 -2
package/packages/proxy/index.mjs
CHANGED
|
@@ -54,6 +54,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
54
54
|
? await haechi.protectJson(json, {
|
|
55
55
|
...routeContext,
|
|
56
56
|
operation: `request:${routeContext.operation}`,
|
|
57
|
+
direction: "request",
|
|
57
58
|
mode: config.policy.mode ?? config.mode
|
|
58
59
|
})
|
|
59
60
|
: { payload: json, blocked: false };
|
|
@@ -77,7 +78,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
77
78
|
const forwarded = await maybeProtectResponse({
|
|
78
79
|
upstreamResponse,
|
|
79
80
|
routeContext,
|
|
80
|
-
runtime
|
|
81
|
+
runtime,
|
|
82
|
+
issuedTokens: result.issuedTokens ?? []
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
response.writeHead(forwarded.status, forwarded.headers);
|
|
@@ -112,7 +114,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
112
114
|
};
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
async function maybeProtectResponse({ upstreamResponse, routeContext, runtime }) {
|
|
117
|
+
async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, issuedTokens = [] }) {
|
|
116
118
|
const headers = Object.fromEntries(upstreamResponse.headers.entries());
|
|
117
119
|
|
|
118
120
|
if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
|
|
@@ -203,6 +205,7 @@ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime })
|
|
|
203
205
|
const result = await runtime.haechi.protectJson(json, {
|
|
204
206
|
...routeContext,
|
|
205
207
|
operation: `response:${routeContext.operation}`,
|
|
208
|
+
direction: "response",
|
|
206
209
|
mode: runtime.config.responseProtection.mode ?? runtime.config.policy.mode ?? runtime.config.mode
|
|
207
210
|
});
|
|
208
211
|
|
|
@@ -218,13 +221,47 @@ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime })
|
|
|
218
221
|
};
|
|
219
222
|
}
|
|
220
223
|
|
|
224
|
+
let responsePayload = result.payload;
|
|
225
|
+
|
|
226
|
+
// Request-scoped token round-trip: restore ONLY tokens issued/reused while
|
|
227
|
+
// protecting this request, so the model sees tokens but the caller sees
|
|
228
|
+
// plaintext. Explicit opt-in; runs after response protection, so an opt-in
|
|
229
|
+
// here intentionally overrides response-direction transforms for values the
|
|
230
|
+
// caller already sent.
|
|
231
|
+
if (runtime.config.tokenVault.detokenizeResponses
|
|
232
|
+
&& issuedTokens.length > 0
|
|
233
|
+
&& typeof runtime.tokenVault?.detokenize === "function") {
|
|
234
|
+
const values = await runtime.tokenVault.detokenize({ tokens: issuedTokens });
|
|
235
|
+
if (values.size > 0) {
|
|
236
|
+
responsePayload = restoreTokens(responsePayload, values);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
221
240
|
return {
|
|
222
241
|
status: upstreamResponse.status,
|
|
223
242
|
headers: transformedJsonHeaders(headers),
|
|
224
|
-
body: Buffer.from(`${JSON.stringify(
|
|
243
|
+
body: Buffer.from(`${JSON.stringify(responsePayload)}\n`)
|
|
225
244
|
};
|
|
226
245
|
}
|
|
227
246
|
|
|
247
|
+
function restoreTokens(value, tokenValues) {
|
|
248
|
+
if (typeof value === "string") {
|
|
249
|
+
let output = value;
|
|
250
|
+
for (const [token, plaintext] of tokenValues) {
|
|
251
|
+
output = output.split(`[TOKEN:${token}]`).join(plaintext);
|
|
252
|
+
}
|
|
253
|
+
return output;
|
|
254
|
+
}
|
|
255
|
+
if (Array.isArray(value)) {
|
|
256
|
+
return value.map((item) => restoreTokens(item, tokenValues));
|
|
257
|
+
}
|
|
258
|
+
if (value && typeof value === "object") {
|
|
259
|
+
return Object.fromEntries(Object.entries(value)
|
|
260
|
+
.map(([key, item]) => [restoreTokens(key, tokenValues), restoreTokens(item, tokenValues)]));
|
|
261
|
+
}
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
|
|
228
265
|
async function forward({ upstream, request, body, timeoutMs = null }) {
|
|
229
266
|
const target = buildUpstreamUrl({ upstream, requestUrl: request.url });
|
|
230
267
|
try {
|
|
@@ -462,6 +499,7 @@ async function recordProxyDecision({ runtime, routeContext, decision, reason, en
|
|
|
462
499
|
protocol: routeContext?.protocol ?? "proxy",
|
|
463
500
|
operation: routeContext ? `proxy:${routeContext.protocol}:${routeContext.routeId ?? "unknown"}` : "proxy",
|
|
464
501
|
mode: runtime.config.policy.mode ?? runtime.config.mode,
|
|
502
|
+
identity: null,
|
|
465
503
|
enforced,
|
|
466
504
|
blocked,
|
|
467
505
|
decision,
|
|
@@ -3,13 +3,33 @@ import { dirname } from "node:path";
|
|
|
3
3
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const DETERMINISTIC_DOMAIN = "haechi:token-vault:deterministic:v1";
|
|
7
|
+
|
|
8
|
+
export function createLocalTokenVault({
|
|
9
|
+
path,
|
|
10
|
+
cryptoProvider,
|
|
11
|
+
revealPolicy = "disabled",
|
|
12
|
+
retentionDays = 30,
|
|
13
|
+
auditSink = null,
|
|
14
|
+
deterministic = false,
|
|
15
|
+
deterministicTypes = null
|
|
16
|
+
}) {
|
|
7
17
|
if (!path) {
|
|
8
18
|
throw new Error("Local token vault requires path");
|
|
9
19
|
}
|
|
10
20
|
if (!cryptoProvider) {
|
|
11
21
|
throw new Error("Local token vault requires cryptoProvider");
|
|
12
22
|
}
|
|
23
|
+
if (deterministic && typeof cryptoProvider.hmac !== "function") {
|
|
24
|
+
throw new Error("Deterministic tokenization requires a cryptoProvider with hmac()");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isDeterministicType(type) {
|
|
28
|
+
if (!deterministic) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return !deterministicTypes || deterministicTypes.includes(type);
|
|
32
|
+
}
|
|
13
33
|
|
|
14
34
|
let mutationQueue = Promise.resolve();
|
|
15
35
|
async function enqueueMutation(operation) {
|
|
@@ -32,6 +52,7 @@ export function createLocalTokenVault({ path, cryptoProvider, revealPolicy = "di
|
|
|
32
52
|
timestamp: new Date().toISOString(),
|
|
33
53
|
protocol: "token-vault",
|
|
34
54
|
operation,
|
|
55
|
+
identity: null,
|
|
35
56
|
mode: "n/a",
|
|
36
57
|
enforced: true,
|
|
37
58
|
blocked: decision.endsWith("_denied"),
|
|
@@ -62,10 +83,26 @@ export function createLocalTokenVault({ path, cryptoProvider, revealPolicy = "di
|
|
|
62
83
|
revealPolicy
|
|
63
84
|
},
|
|
64
85
|
async tokenize({ plaintext, type, context = {}, metadata = {} }) {
|
|
86
|
+
// Deterministic tokens are derived outside the mutation lock (HMAC reads
|
|
87
|
+
// only the key file); the same (type, value) always maps to one token.
|
|
88
|
+
const token = isDeterministicType(type)
|
|
89
|
+
? `tok_${type}_${(await cryptoProvider.hmac({
|
|
90
|
+
data: `${type}:${plaintext}`,
|
|
91
|
+
domain: DETERMINISTIC_DOMAIN
|
|
92
|
+
})).slice(0, 32)}`
|
|
93
|
+
: `tok_${type}_${shortHash(`${plaintext}:${randomBytes(16).toString("hex")}`)}`;
|
|
94
|
+
|
|
65
95
|
return enqueueMutation(async () => {
|
|
66
96
|
const vault = await readVault(path);
|
|
67
97
|
pruneExpiredTokens(vault);
|
|
68
|
-
|
|
98
|
+
|
|
99
|
+
const existing = vault.tokens[token];
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.expiresAt = addDays(new Date(), retentionDays).toISOString();
|
|
102
|
+
await writeVault(path, vault);
|
|
103
|
+
return { token, type, reused: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
69
106
|
const createdAt = new Date();
|
|
70
107
|
const aad = {
|
|
71
108
|
purpose: "token-vault",
|
|
@@ -127,6 +164,37 @@ export function createLocalTokenVault({ path, cryptoProvider, revealPolicy = "di
|
|
|
127
164
|
throw error;
|
|
128
165
|
}
|
|
129
166
|
},
|
|
167
|
+
// Request-scoped response restoration. Deliberately NOT gated by
|
|
168
|
+
// revealPolicy: that governs manual/CLI reveal, while detokenize is only
|
|
169
|
+
// reachable through the proxy's explicit detokenizeResponses opt-in and is
|
|
170
|
+
// limited to the caller-supplied token set. Audited by count, no plaintext.
|
|
171
|
+
async detokenize({ tokens }) {
|
|
172
|
+
const vault = await readVault(path);
|
|
173
|
+
const values = new Map();
|
|
174
|
+
let skipped = 0;
|
|
175
|
+
|
|
176
|
+
for (const token of tokens) {
|
|
177
|
+
const record = vault.tokens[token];
|
|
178
|
+
if (!record || (record.expiresAt && Date.parse(record.expiresAt) < Date.now())) {
|
|
179
|
+
skipped += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
values.set(token, await cryptoProvider.decrypt({ envelope: record.envelope, aad: record.aad }));
|
|
184
|
+
} catch {
|
|
185
|
+
skipped += 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await recordVaultEvent({
|
|
190
|
+
operation: "token-vault:detokenize",
|
|
191
|
+
decision: "detokenize",
|
|
192
|
+
count: values.size,
|
|
193
|
+
reason: skipped > 0 ? `${skipped} tokens not restored` : null
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return values;
|
|
197
|
+
},
|
|
130
198
|
async purge({ token }) {
|
|
131
199
|
return enqueueMutation(async () => {
|
|
132
200
|
const vault = await readVault(path);
|