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,287 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { createHaechi } from "../core/index.mjs";
4
+ import { createDefaultFilterEngine } from "../filter/index.mjs";
5
+ import { buildPolicy, createPolicyEngine } from "../policy/index.mjs";
6
+ import { createLocalCryptoProvider, initLocalKeyFile } from "../crypto/index.mjs";
7
+ import { createJsonlAuditSink } from "../audit/index.mjs";
8
+ import { createLocalTokenVault } from "../token-vault/index.mjs";
9
+ import { loadVerifiedPolicyBundleFileSync } from "../policy-bundle/index.mjs";
10
+ import { createProtocolAdapter } from "../protocol-adapters/index.mjs";
11
+ import { applyPrivacyProfile, getPrivacyProfile } from "../privacy-profiles/index.mjs";
12
+ import { DEFAULT_PROXY_PORT } from "../proxy/index.mjs";
13
+
14
+ export const DEFAULT_CONFIG_PATH = "haechi.config.json";
15
+
16
+ export function defaultConfig() {
17
+ return {
18
+ mode: "dry-run",
19
+ target: {
20
+ type: "llm-http",
21
+ adapter: "openai-compatible",
22
+ upstream: "http://127.0.0.1:9999"
23
+ },
24
+ proxy: {
25
+ host: "127.0.0.1",
26
+ port: DEFAULT_PROXY_PORT
27
+ },
28
+ responseProtection: {
29
+ enabled: false,
30
+ mode: "enforce",
31
+ failureMode: "fail-closed",
32
+ allowNonJson: false,
33
+ allowCompressed: false,
34
+ maxBytes: 1048576
35
+ },
36
+ streaming: {
37
+ requestMode: "block"
38
+ },
39
+ limits: {
40
+ maxRequestBytes: 1048576,
41
+ upstreamTimeoutMs: 120000
42
+ },
43
+ policy: {
44
+ mode: "dry-run",
45
+ presets: ["korean-pii", "secrets-only", "llm-redact"],
46
+ defaultAction: "redact",
47
+ actions: {
48
+ card: "block"
49
+ }
50
+ },
51
+ filters: {
52
+ customRules: []
53
+ },
54
+ keys: {
55
+ provider: "local",
56
+ keyFile: ".haechi/dev.keys.json"
57
+ },
58
+ audit: {
59
+ sink: "jsonl",
60
+ path: ".haechi/audit.jsonl"
61
+ },
62
+ tokenVault: {
63
+ provider: "local",
64
+ path: ".haechi/token-vault.json",
65
+ revealPolicy: "disabled",
66
+ retentionDays: 30
67
+ },
68
+ privacy: {
69
+ profile: null
70
+ },
71
+ mcp: {
72
+ allowedMethods: ["initialize", "tools/call", "resources/read", "prompts/get"],
73
+ protectParams: true,
74
+ protectResults: true,
75
+ requireJsonRpc: true
76
+ }
77
+ };
78
+ }
79
+
80
+ export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) {
81
+ const raw = JSON.parse(await readFile(configPath, "utf8"));
82
+ return normalizeConfig(raw);
83
+ }
84
+
85
+ export async function writeDefaultConfig(configPath = DEFAULT_CONFIG_PATH, { force = false } = {}) {
86
+ const config = defaultConfig();
87
+ await mkdir(dirname(config.keys.keyFile), { recursive: true });
88
+ await initLocalKeyFile(config.keys.keyFile, { force });
89
+
90
+ if (!force) {
91
+ try {
92
+ await readFile(configPath, "utf8");
93
+ return { created: false, configPath, config };
94
+ } catch (error) {
95
+ if (error.code !== "ENOENT") {
96
+ throw error;
97
+ }
98
+ }
99
+ }
100
+
101
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
102
+ return { created: true, configPath, config };
103
+ }
104
+
105
+ export function createRuntime(config, providers = {}) {
106
+ const normalized = normalizeConfig(config);
107
+ const cryptoProvider = providers.cryptoProvider ?? createConfiguredCryptoProvider(normalized);
108
+ assertProvider("cryptoProvider", cryptoProvider, ["encrypt", "decrypt"]);
109
+ const auditSink = providers.auditSink ?? createJsonlAuditSink({ path: normalized.audit.path });
110
+ assertProvider("auditSink", auditSink, ["record"]);
111
+ const tokenVault = providers.tokenVault ?? createLocalTokenVault({
112
+ path: normalized.tokenVault.path,
113
+ cryptoProvider,
114
+ revealPolicy: normalized.tokenVault.revealPolicy,
115
+ retentionDays: normalized.tokenVault.retentionDays,
116
+ auditSink
117
+ });
118
+ assertProvider("tokenVault", tokenVault, ["tokenize", "reveal", "purge"]);
119
+ const policySource = normalized.policy.bundlePath
120
+ ? {
121
+ ...loadVerifiedPolicyBundleFileSync({
122
+ bundlePath: normalized.policy.bundlePath,
123
+ keyFile: normalized.keys.keyFile
124
+ }).policy,
125
+ mode: normalized.policy.mode ?? normalized.mode
126
+ }
127
+ : {
128
+ ...normalized.policy,
129
+ mode: normalized.policy.mode ?? normalized.mode
130
+ };
131
+ const policy = buildPolicy(normalized.privacy.profile
132
+ ? applyPrivacyProfile(policySource, normalized.privacy.profile)
133
+ : policySource);
134
+
135
+ const filterEngine = providers.filterEngine ?? createDefaultFilterEngine(normalized.filters);
136
+ assertProvider("filterEngine", filterEngine, ["detect"]);
137
+ const policyEngine = providers.policyEngine ?? createPolicyEngine(policy);
138
+ assertProvider("policyEngine", policyEngine, ["decide"]);
139
+
140
+ return {
141
+ config: normalized,
142
+ tokenVault,
143
+ auditSink,
144
+ protocolAdapter: createProtocolAdapter(normalized.target),
145
+ haechi: createHaechi({
146
+ mode: normalized.mode,
147
+ filterEngine,
148
+ policyEngine,
149
+ cryptoProvider,
150
+ tokenVault,
151
+ auditSink
152
+ })
153
+ };
154
+ }
155
+
156
+ export function normalizeConfig(config) {
157
+ const merged = {
158
+ ...defaultConfig(),
159
+ ...config,
160
+ target: {
161
+ ...defaultConfig().target,
162
+ ...(config.target ?? {})
163
+ },
164
+ proxy: {
165
+ ...defaultConfig().proxy,
166
+ ...(config.proxy ?? {})
167
+ },
168
+ responseProtection: {
169
+ ...defaultConfig().responseProtection,
170
+ ...(config.responseProtection ?? {})
171
+ },
172
+ streaming: {
173
+ ...defaultConfig().streaming,
174
+ ...(config.streaming ?? {})
175
+ },
176
+ limits: {
177
+ ...defaultConfig().limits,
178
+ ...(config.limits ?? {})
179
+ },
180
+ policy: {
181
+ ...defaultConfig().policy,
182
+ ...(config.policy ?? {}),
183
+ actions: {
184
+ ...defaultConfig().policy.actions,
185
+ ...(config.policy?.actions ?? {})
186
+ }
187
+ },
188
+ filters: {
189
+ ...defaultConfig().filters,
190
+ ...(config.filters ?? {})
191
+ },
192
+ keys: {
193
+ ...defaultConfig().keys,
194
+ ...(config.keys ?? {})
195
+ },
196
+ audit: {
197
+ ...defaultConfig().audit,
198
+ ...(config.audit ?? {})
199
+ },
200
+ tokenVault: {
201
+ ...defaultConfig().tokenVault,
202
+ ...(config.tokenVault ?? {})
203
+ },
204
+ privacy: {
205
+ ...defaultConfig().privacy,
206
+ ...(config.privacy ?? {})
207
+ },
208
+ mcp: {
209
+ ...defaultConfig().mcp,
210
+ ...(config.mcp ?? {}),
211
+ allowedMethods: config.mcp?.allowedMethods ?? defaultConfig().mcp.allowedMethods
212
+ }
213
+ };
214
+
215
+ if (!["local", "external"].includes(merged.keys.provider)) {
216
+ throw new Error(`Unsupported key provider: ${merged.keys.provider}`);
217
+ }
218
+ if (typeof merged.proxy.host !== "string" || !merged.proxy.host.trim()) {
219
+ throw new Error("proxy.host must be a non-empty string");
220
+ }
221
+ if (!isValidPort(merged.proxy.port)) {
222
+ throw new Error("proxy.port must be an integer from 0 to 65535");
223
+ }
224
+ if (merged.audit.sink !== "jsonl") {
225
+ throw new Error("Current implementation only supports jsonl audit sink");
226
+ }
227
+ if (merged.tokenVault.provider !== "local") {
228
+ throw new Error("0.2 only supports local token vault provider");
229
+ }
230
+ if (!["disabled", "local-dev"].includes(merged.tokenVault.revealPolicy)) {
231
+ throw new Error(`Invalid tokenVault.revealPolicy: ${merged.tokenVault.revealPolicy}`);
232
+ }
233
+ if (typeof merged.tokenVault.retentionDays !== "number" || merged.tokenVault.retentionDays < 1) {
234
+ throw new Error("tokenVault.retentionDays must be a positive number");
235
+ }
236
+ if (!Array.isArray(merged.mcp.allowedMethods) || merged.mcp.allowedMethods.length === 0) {
237
+ throw new Error("mcp.allowedMethods must be a non-empty array");
238
+ }
239
+ if (!merged.mcp.allowedMethods.every((method) => typeof method === "string" && method.trim())) {
240
+ throw new Error("mcp.allowedMethods must contain only non-empty strings");
241
+ }
242
+ if (typeof merged.mcp.protectParams !== "boolean" || typeof merged.mcp.protectResults !== "boolean") {
243
+ throw new Error("mcp.protectParams and mcp.protectResults must be boolean");
244
+ }
245
+ if (typeof merged.mcp.requireJsonRpc !== "boolean") {
246
+ throw new Error("mcp.requireJsonRpc must be boolean");
247
+ }
248
+ if (merged.privacy.profile) {
249
+ getPrivacyProfile(merged.privacy.profile);
250
+ }
251
+ if (!["fail-closed", "allow"].includes(merged.responseProtection.failureMode)) {
252
+ throw new Error(`Invalid responseProtection.failureMode: ${merged.responseProtection.failureMode}`);
253
+ }
254
+ if (typeof merged.responseProtection.maxBytes !== "number" || merged.responseProtection.maxBytes < 1) {
255
+ throw new Error("responseProtection.maxBytes must be a positive number");
256
+ }
257
+ if (!["block", "pass-through"].includes(merged.streaming.requestMode)) {
258
+ throw new Error(`Invalid streaming.requestMode: ${merged.streaming.requestMode}`);
259
+ }
260
+ if (typeof merged.limits.maxRequestBytes !== "number" || merged.limits.maxRequestBytes < 1) {
261
+ throw new Error("limits.maxRequestBytes must be a positive number");
262
+ }
263
+ if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
264
+ throw new Error("limits.upstreamTimeoutMs must be a positive number");
265
+ }
266
+ createProtocolAdapter(merged.target);
267
+ return merged;
268
+ }
269
+
270
+ export function isValidPort(port) {
271
+ return Number.isInteger(port) && port >= 0 && port <= 65535;
272
+ }
273
+
274
+ function createConfiguredCryptoProvider(config) {
275
+ if (config.keys.provider === "external") {
276
+ throw new Error("keys.provider external requires createRuntime(config, { cryptoProvider })");
277
+ }
278
+ return createLocalCryptoProvider({ keyFile: config.keys.keyFile });
279
+ }
280
+
281
+ function assertProvider(name, provider, methods) {
282
+ for (const method of methods) {
283
+ if (typeof provider?.[method] !== "function") {
284
+ throw new Error(`${name} provider must implement ${method}()`);
285
+ }
286
+ }
287
+ }
@@ -0,0 +1,309 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+
3
+ const NO_ENFORCE_MODES = new Set(["dry-run", "report-only"]);
4
+
5
+ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, auditSink, tokenVault = null, mode = "dry-run" }) {
6
+ if (!filterEngine || !policyEngine || !cryptoProvider || !auditSink) {
7
+ throw new Error("Haechi requires filterEngine, policyEngine, cryptoProvider, and auditSink");
8
+ }
9
+
10
+ async function protectJson(payload, context = {}) {
11
+ const effectiveMode = context.mode ?? mode;
12
+ const entries = collectStringEntries(payload);
13
+ const detections = await filterEngine.detect({ entries, context });
14
+ const decisions = [];
15
+
16
+ for (const detection of detections) {
17
+ decisions.push(await policyEngine.decide({ detection, context, mode: effectiveMode }));
18
+ }
19
+
20
+ const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
21
+ const blocked = enforced && decisions.some((decision) => decision.action === "block");
22
+ const protectedPayload = blocked ? null : await transformPayload(payload, detections, decisions, {
23
+ context,
24
+ cryptoProvider,
25
+ tokenVault,
26
+ enforced
27
+ });
28
+
29
+ const auditEvent = buildAuditEvent({
30
+ context,
31
+ mode: effectiveMode,
32
+ enforced,
33
+ blocked,
34
+ payload,
35
+ detections,
36
+ decisions
37
+ });
38
+
39
+ await auditSink.record(auditEvent);
40
+
41
+ return {
42
+ payload: protectedPayload,
43
+ blocked,
44
+ summary: summarize(detections, decisions),
45
+ auditEvent
46
+ };
47
+ }
48
+
49
+ return { protectJson };
50
+ }
51
+
52
+ export function collectStringEntries(value, path = []) {
53
+ if (typeof value === "string") {
54
+ return [{ path, pathText: safePathToString(path), value, kind: "value" }];
55
+ }
56
+
57
+ // Long digit runs (e.g. card numbers) can arrive as JSON numbers; scan their
58
+ // string form so numeric leaves are not a detection blind spot.
59
+ if (typeof value === "number" && Number.isFinite(value)) {
60
+ return [{ path, pathText: safePathToString(path), value: String(value), kind: "number" }];
61
+ }
62
+
63
+ if (Array.isArray(value)) {
64
+ return value.flatMap((item, index) => collectStringEntries(item, path.concat(index)));
65
+ }
66
+
67
+ if (value && typeof value === "object") {
68
+ // Object keys are scanned too: a PII/secret used as a map key would
69
+ // otherwise be forwarded upstream in plaintext.
70
+ return Object.entries(value).flatMap(([key, item]) => [
71
+ { path: path.concat(key), pathText: safePathToString(path.concat(key)), value: key, kind: "key" },
72
+ ...collectStringEntries(item, path.concat(key))
73
+ ]);
74
+ }
75
+
76
+ return [];
77
+ }
78
+
79
+ export function pathToString(path) {
80
+ return path.reduce((text, part, index) => {
81
+ if (typeof part === "number") {
82
+ return `${text}[${part}]`;
83
+ }
84
+ return index === 0 ? String(part) : `${text}.${part}`;
85
+ }, "");
86
+ }
87
+
88
+ export function safePathToString(path) {
89
+ return path.reduce((text, part, index) => {
90
+ if (typeof part === "number") {
91
+ return `${text}[${part}]`;
92
+ }
93
+ const safePart = `key_${shortHash(String(part))}`;
94
+ return index === 0 ? safePart : `${text}.${safePart}`;
95
+ }, "");
96
+ }
97
+
98
+ export function shapeOnly(value) {
99
+ if (typeof value === "string") {
100
+ return { type: "string", length: value.length };
101
+ }
102
+ if (Array.isArray(value)) {
103
+ return value.map((item) => shapeOnly(item));
104
+ }
105
+ if (value && typeof value === "object") {
106
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, shapeOnly(item)]));
107
+ }
108
+ return { type: value === null ? "null" : typeof value };
109
+ }
110
+
111
+ export function summarize(detections, decisions) {
112
+ const byType = {};
113
+ const byAction = {};
114
+
115
+ for (const detection of detections) {
116
+ byType[detection.type] = (byType[detection.type] ?? 0) + 1;
117
+ }
118
+
119
+ for (const decision of decisions) {
120
+ byAction[decision.action] = (byAction[decision.action] ?? 0) + 1;
121
+ }
122
+
123
+ return {
124
+ detectionCount: detections.length,
125
+ byType,
126
+ byAction
127
+ };
128
+ }
129
+
130
+ async function transformPayload(payload, detections, decisions, { context, cryptoProvider, tokenVault, enforced }) {
131
+ if (!enforced || detections.length === 0) {
132
+ return structuredClone(payload);
133
+ }
134
+
135
+ const output = structuredClone(payload);
136
+ const byPath = new Map();
137
+
138
+ detections.forEach((detection, index) => {
139
+ const key = JSON.stringify([detection.kind ?? "value", detection.path]);
140
+ const field = byPath.get(key) ?? [];
141
+ field.push({ detection, decision: decisions[index] });
142
+ byPath.set(key, field);
143
+ });
144
+
145
+ const valueGroups = [];
146
+ const keyGroups = [];
147
+ for (const [groupKey, items] of byPath.entries()) {
148
+ const [kind, path] = JSON.parse(groupKey);
149
+ (kind === "key" ? keyGroups : valueGroups).push({ kind, path, items });
150
+ }
151
+
152
+ for (const { kind, path, items } of valueGroups) {
153
+ const original = getByPath(output, path);
154
+ if (kind === "number") {
155
+ if (typeof original !== "number") {
156
+ continue;
157
+ }
158
+ const transformed = await transformString(String(original), items, { context, cryptoProvider, tokenVault });
159
+ if (transformed !== String(original)) {
160
+ setByPath(output, path, transformed);
161
+ }
162
+ continue;
163
+ }
164
+ if (typeof original !== "string") {
165
+ continue;
166
+ }
167
+ const transformed = await transformString(original, items, { context, cryptoProvider, tokenVault });
168
+ setByPath(output, path, transformed);
169
+ }
170
+
171
+ // Key renames run after value transforms (value paths reference original
172
+ // keys), deepest first so ancestor paths stay valid while renaming.
173
+ keyGroups.sort((left, right) => right.path.length - left.path.length);
174
+ for (const { path, items } of keyGroups) {
175
+ const parentPath = path.slice(0, -1);
176
+ const parent = parentPath.length > 0 ? getByPath(output, parentPath) : output;
177
+ const originalKey = path.at(-1);
178
+ if (!parent || typeof parent !== "object" || Array.isArray(parent)
179
+ || !Object.prototype.hasOwnProperty.call(parent, originalKey)) {
180
+ continue;
181
+ }
182
+ const transformedKey = await transformString(String(originalKey), items, { context, cryptoProvider, tokenVault });
183
+ if (transformedKey === originalKey) {
184
+ continue;
185
+ }
186
+ const childValue = parent[originalKey];
187
+ delete parent[originalKey];
188
+ let nextKey = transformedKey;
189
+ let suffix = 2;
190
+ while (Object.prototype.hasOwnProperty.call(parent, nextKey)) {
191
+ nextKey = `${transformedKey}#${suffix}`;
192
+ suffix += 1;
193
+ }
194
+ parent[nextKey] = childValue;
195
+ }
196
+
197
+ return output;
198
+ }
199
+
200
+ async function transformString(value, items, { context, cryptoProvider, tokenVault }) {
201
+ const sorted = items
202
+ .filter(({ decision }) => decision.action !== "allow" && decision.action !== "block")
203
+ .sort((left, right) => left.detection.start - right.detection.start);
204
+
205
+ let cursor = 0;
206
+ let output = "";
207
+
208
+ for (const { detection, decision } of sorted) {
209
+ if (detection.start < cursor) {
210
+ continue;
211
+ }
212
+
213
+ output += value.slice(cursor, detection.start);
214
+ const segment = value.slice(detection.start, detection.end);
215
+ output += await replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault });
216
+ cursor = detection.end;
217
+ }
218
+
219
+ output += value.slice(cursor);
220
+ return output;
221
+ }
222
+
223
+ async function replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault }) {
224
+ switch (decision.action) {
225
+ case "redact":
226
+ return `[REDACTED:${detection.type}]`;
227
+ case "mask":
228
+ return maskSensitive(segment);
229
+ case "tokenize":
230
+ if (tokenVault) {
231
+ const result = await tokenVault.tokenize({
232
+ plaintext: segment,
233
+ type: detection.type,
234
+ context,
235
+ metadata: {
236
+ path: detection.pathText,
237
+ ruleId: detection.ruleId
238
+ }
239
+ });
240
+ return `[TOKEN:${result.token}]`;
241
+ }
242
+ return `[TOKEN:${detection.type}:${shortHash(segment)}]`;
243
+ case "encrypt": {
244
+ const envelope = await cryptoProvider.encrypt({
245
+ plaintext: segment,
246
+ aad: {
247
+ context,
248
+ path: detection.pathText,
249
+ type: detection.type,
250
+ ruleId: detection.ruleId
251
+ }
252
+ });
253
+ return `[HAECHI_ENC:${base64UrlEncode(JSON.stringify(envelope))}]`;
254
+ }
255
+ default:
256
+ return segment;
257
+ }
258
+ }
259
+
260
+ function buildAuditEvent({ context, mode, enforced, blocked, payload, detections, decisions }) {
261
+ return {
262
+ id: randomUUID(),
263
+ timestamp: new Date().toISOString(),
264
+ protocol: context.protocol ?? "custom",
265
+ operation: context.operation ?? "protect",
266
+ mode,
267
+ enforced,
268
+ blocked,
269
+ payloadShapeHash: shortHash(JSON.stringify(shapeOnly(payload))),
270
+ detections: detections.map((detection, index) => ({
271
+ type: detection.type,
272
+ ruleId: detection.ruleId,
273
+ path: detection.pathText,
274
+ kind: detection.kind ?? "value",
275
+ confidence: detection.confidence,
276
+ action: decisions[index]?.action ?? "unknown",
277
+ enforced
278
+ })),
279
+ summary: summarize(detections, decisions)
280
+ };
281
+ }
282
+
283
+ function getByPath(value, path) {
284
+ return path.reduce((current, part) => current?.[part], value);
285
+ }
286
+
287
+ function setByPath(value, path, nextValue) {
288
+ let current = value;
289
+ for (let index = 0; index < path.length - 1; index += 1) {
290
+ current = current[path[index]];
291
+ }
292
+ current[path[path.length - 1]] = nextValue;
293
+ }
294
+
295
+ function maskSensitive(value) {
296
+ // Short values would leak most of their content through partial masking.
297
+ if (value.length <= 8) {
298
+ return "*".repeat(value.length);
299
+ }
300
+ return `${value.slice(0, 2)}${"*".repeat(Math.max(4, value.length - 4))}${value.slice(-2)}`;
301
+ }
302
+
303
+ function shortHash(value) {
304
+ return createHash("sha256").update(value).digest("hex").slice(0, 12);
305
+ }
306
+
307
+ function base64UrlEncode(value) {
308
+ return Buffer.from(value, "utf8").toString("base64url");
309
+ }