haechi 0.3.2 → 0.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.
Files changed (33) hide show
  1. package/README.ko.md +227 -0
  2. package/README.md +126 -1
  3. package/docs/README.md +3 -6
  4. package/docs/current/api-stability.ko.md +11 -4
  5. package/docs/current/api-stability.md +11 -4
  6. package/docs/current/configuration.ko.md +210 -0
  7. package/docs/current/configuration.md +210 -0
  8. package/docs/current/release-0.3.2-hardening-scope.ko.md +2 -1
  9. package/docs/current/release-0.3.2-hardening-scope.md +2 -1
  10. package/docs/current/release-0.4-implementation-scope.ko.md +2 -1
  11. package/docs/current/release-0.4-implementation-scope.md +2 -1
  12. package/docs/current/release-0.5-implementation-scope.ko.md +69 -0
  13. package/docs/current/release-0.5-implementation-scope.md +69 -0
  14. package/docs/current/release-process.ko.md +14 -4
  15. package/docs/current/release-process.md +14 -4
  16. package/docs/current/risk-register-release-gate.ko.md +11 -11
  17. package/docs/current/risk-register-release-gate.md +12 -12
  18. package/docs/current/threat-model.ko.md +8 -4
  19. package/docs/current/threat-model.md +8 -4
  20. package/haechi.config.example.json +7 -2
  21. package/package.json +8 -2
  22. package/packages/audit/index.mjs +3 -1
  23. package/packages/cli/bin/haechi.mjs +310 -21
  24. package/packages/cli/runtime.mjs +28 -3
  25. package/packages/core/index.mjs +128 -10
  26. package/packages/crypto/index.mjs +13 -1
  27. package/packages/filter/index.mjs +52 -3
  28. package/packages/mcp-stdio/index.mjs +103 -22
  29. package/packages/policy/index.mjs +6 -0
  30. package/packages/protocol-adapters/index.mjs +33 -14
  31. package/packages/proxy/index.mjs +149 -4
  32. package/packages/stream-filter/index.mjs +194 -0
  33. package/packages/token-vault/index.mjs +70 -2
@@ -0,0 +1,194 @@
1
+ // SSE / NDJSON streaming response inspection.
2
+ //
3
+ // Frames are parsed incrementally, the primary delta-text channel is run
4
+ // through a bounded sliding buffer (cross-frame matches caught up to
5
+ // streaming.maxMatchBytes), and all other string leaves in a frame get
6
+ // within-frame protection. The whole stream is audited once at the end.
7
+
8
+ const SSE_DONE = "[DONE]";
9
+
10
+ export async function inspectResponseStream({ source, sink, streaming, protector, format }) {
11
+ const wireFormat = format ?? streaming?.format ?? "ndjson";
12
+ const deltaPath = streaming?.deltaPath ?? null;
13
+ const decoder = new TextDecoder("utf-8");
14
+ const frames = createFrameSplitter(wireFormat);
15
+
16
+ let blocked = false;
17
+
18
+ async function handleFrame(raw) {
19
+ const frame = { raw, body: raw.trim() };
20
+ const parsed = parseFrame(frame, wireFormat);
21
+ if (!parsed.ok) {
22
+ // Non-JSON frame (e.g. `data: [DONE]`, comments, keep-alives): pass
23
+ // through verbatim — there is nothing to inspect.
24
+ sink.write(frame.raw);
25
+ return;
26
+ }
27
+
28
+ const json = parsed.json;
29
+ let deltaText = null;
30
+ if (deltaPath) {
31
+ const found = getByPath(json, deltaPath);
32
+ if (typeof found === "string") {
33
+ deltaText = found;
34
+ setByPath(json, deltaPath, "");
35
+ }
36
+ }
37
+
38
+ // Within-frame protection for everything except the delta channel.
39
+ const extras = await protector.protectFrameExtras(json);
40
+ if (extras.blocked) {
41
+ blocked = true;
42
+ return;
43
+ }
44
+ const frameObject = extras.value;
45
+
46
+ if (deltaText !== null) {
47
+ const pushed = await protector.push(deltaText);
48
+ if (pushed.blocked) {
49
+ blocked = true;
50
+ return;
51
+ }
52
+ setByPath(frameObject, deltaPath, pushed.text);
53
+ }
54
+
55
+ sink.write(serializeFrame(frameObject, wireFormat, frame));
56
+ }
57
+
58
+ for await (const chunk of source) {
59
+ for (const frame of frames.push(decoder.decode(chunk, { stream: true }))) {
60
+ await handleFrame(frame);
61
+ if (blocked) {
62
+ break;
63
+ }
64
+ }
65
+ if (blocked) {
66
+ break;
67
+ }
68
+ }
69
+
70
+ if (!blocked) {
71
+ for (const frame of frames.end(decoder.decode())) {
72
+ await handleFrame(frame);
73
+ if (blocked) {
74
+ break;
75
+ }
76
+ }
77
+ }
78
+
79
+ if (!blocked) {
80
+ // Flush the held tail of the delta buffer as a synthesized final frame.
81
+ const flushed = await protector.flush();
82
+ if (flushed.blocked) {
83
+ blocked = true;
84
+ } else if (flushed.text && deltaPath) {
85
+ sink.write(serializeFrame(buildPathObject(deltaPath, flushed.text), wireFormat, null));
86
+ }
87
+ }
88
+
89
+ // The caller closes the sink AFTER recording the stream decision, so the
90
+ // audit write is durable before the client connection ends.
91
+ return { blocked, summary: protector.summary() };
92
+ }
93
+
94
+ function createFrameSplitter(format) {
95
+ const delimiter = format === "sse" ? "\n\n" : "\n";
96
+ let buffer = "";
97
+ return {
98
+ // Append text and return the raw text of every complete frame now
99
+ // available; the trailing partial is retained for the next push.
100
+ push(text) {
101
+ buffer += text;
102
+ const out = [];
103
+ let index;
104
+ while ((index = buffer.indexOf(delimiter)) !== -1) {
105
+ const raw = buffer.slice(0, index + delimiter.length);
106
+ buffer = buffer.slice(index + delimiter.length);
107
+ if (raw.trim()) {
108
+ out.push(raw);
109
+ }
110
+ }
111
+ return out;
112
+ },
113
+ // Flush any trailing partial frame at end of stream.
114
+ end(text) {
115
+ buffer += text;
116
+ const remainder = buffer;
117
+ buffer = "";
118
+ return remainder.trim() ? [remainder] : [];
119
+ }
120
+ };
121
+ }
122
+
123
+ function parseFrame(frame, format) {
124
+ if (!frame) {
125
+ return { ok: false };
126
+ }
127
+ let payload = frame.body;
128
+ if (format === "sse") {
129
+ const dataLines = payload
130
+ .split("\n")
131
+ .filter((line) => line.startsWith("data:"))
132
+ .map((line) => line.slice(5).trim());
133
+ if (dataLines.length === 0) {
134
+ return { ok: false };
135
+ }
136
+ payload = dataLines.join("");
137
+ if (payload === SSE_DONE) {
138
+ return { ok: false };
139
+ }
140
+ }
141
+ try {
142
+ return { ok: true, json: JSON.parse(payload) };
143
+ } catch {
144
+ return { ok: false };
145
+ }
146
+ }
147
+
148
+ function serializeFrame(json, format, original) {
149
+ const body = JSON.stringify(json);
150
+ if (format === "sse") {
151
+ return `data: ${body}\n\n`;
152
+ }
153
+ // NDJSON: preserve the original trailing newline style when available.
154
+ return original && original.raw.endsWith("\n") ? `${body}\n` : `${body}\n`;
155
+ }
156
+
157
+ export function getByPath(value, path) {
158
+ let current = value;
159
+ for (const part of path) {
160
+ if (current == null) {
161
+ return undefined;
162
+ }
163
+ current = current[part];
164
+ }
165
+ return current;
166
+ }
167
+
168
+ export function setByPath(value, path, next) {
169
+ let current = value;
170
+ for (let index = 0; index < path.length - 1; index += 1) {
171
+ const part = path[index];
172
+ if (current[part] == null || typeof current[part] !== "object") {
173
+ return false;
174
+ }
175
+ current = current[part];
176
+ }
177
+ if (current == null || typeof current !== "object") {
178
+ return false;
179
+ }
180
+ current[path[path.length - 1]] = next;
181
+ return true;
182
+ }
183
+
184
+ export function buildPathObject(path, leaf) {
185
+ const root = typeof path[0] === "number" ? [] : {};
186
+ let current = root;
187
+ for (let index = 0; index < path.length - 1; index += 1) {
188
+ const nextIsIndex = typeof path[index + 1] === "number";
189
+ current[path[index]] = nextIsIndex ? [] : {};
190
+ current = current[path[index]];
191
+ }
192
+ current[path[path.length - 1]] = leaf;
193
+ return root;
194
+ }
@@ -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
- export function createLocalTokenVault({ path, cryptoProvider, revealPolicy = "disabled", retentionDays = 30, auditSink = null }) {
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
- const token = `tok_${type}_${shortHash(`${plaintext}:${randomBytes(16).toString("hex")}`)}`;
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);