haechi 1.1.2 → 1.3.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 (39) hide show
  1. package/README.ko.md +46 -11
  2. package/README.md +46 -11
  3. package/SECURITY.md +7 -1
  4. package/docs/README.md +2 -0
  5. package/docs/current/compliance-mapping.ko.md +53 -0
  6. package/docs/current/compliance-mapping.md +53 -0
  7. package/docs/current/config-version.ko.md +30 -0
  8. package/docs/current/config-version.md +51 -0
  9. package/docs/current/configuration.ko.md +165 -9
  10. package/docs/current/configuration.md +165 -9
  11. package/docs/current/operations-runbook.ko.md +155 -0
  12. package/docs/current/operations-runbook.md +241 -0
  13. package/docs/current/release-process.ko.md +5 -1
  14. package/docs/current/release-process.md +5 -1
  15. package/docs/current/risk-register-release-gate.ko.md +5 -3
  16. package/docs/current/risk-register-release-gate.md +13 -3
  17. package/docs/current/security-whitepaper.ko.md +102 -0
  18. package/docs/current/security-whitepaper.md +102 -0
  19. package/docs/current/shared-responsibility.ko.md +2 -2
  20. package/docs/current/shared-responsibility.md +2 -2
  21. package/docs/current/threat-model.ko.md +4 -2
  22. package/docs/current/threat-model.md +4 -2
  23. package/examples/local-proxy-demo/README.md +51 -0
  24. package/examples/local-proxy-demo/demo.mjs +144 -0
  25. package/examples/local-proxy-demo/demo.tape +19 -0
  26. package/examples/local-proxy-demo/live-demo.mjs +121 -0
  27. package/examples/local-proxy-demo/live-demo.tape +25 -0
  28. package/haechi.config.example.json +20 -3
  29. package/package.json +7 -2
  30. package/packages/audit/index.mjs +26 -2
  31. package/packages/cli/bin/haechi.mjs +57 -10
  32. package/packages/cli/runtime.mjs +402 -10
  33. package/packages/core/index.mjs +143 -8
  34. package/packages/filter/index.mjs +975 -12
  35. package/packages/metrics/index.mjs +181 -0
  36. package/packages/privacy-profiles/index.mjs +72 -3
  37. package/packages/protocol-adapters/index.mjs +99 -1
  38. package/packages/proxy/index.mjs +525 -40
  39. package/packages/stream-filter/index.mjs +69 -7
@@ -10,10 +10,40 @@ const SSE_DONE = "[DONE]";
10
10
  export async function inspectResponseStream({ source, sink, streaming, protector, format }) {
11
11
  const wireFormat = format ?? streaming?.format ?? "ndjson";
12
12
  const deltaPath = streaming?.deltaPath ?? null;
13
+ // Frame types that TERMINATE a delta sequence (declared per-adapter, e.g.
14
+ // Anthropic's content_block_stop/message_delta/message_stop). Before such a
15
+ // frame the held cross-frame buffer tail is flushed as a valid delta frame, so
16
+ // the residual lands in-order BEFORE the terminator — never after message_stop.
17
+ // Keepalives (ping) are deliberately NOT listed, so a match split across a ping
18
+ // is still caught by the sliding buffer.
19
+ const flushOnType = streaming?.flushOnType ?? null;
13
20
  const decoder = new TextDecoder("utf-8");
14
21
  const frames = createFrameSplitter(wireFormat);
15
22
 
16
23
  let blocked = false;
24
+ // A structural template of the last frame that carried delta text, used to
25
+ // re-emit a held buffer tail as a VALID delta frame (preserving its wire
26
+ // wrapper — Anthropic's `event:` line — plus sibling fields like type/index).
27
+ let lastDeltaTemplate = null;
28
+
29
+ async function flushHeldTail() {
30
+ const flushed = await protector.flush();
31
+ if (flushed.blocked) {
32
+ blocked = true;
33
+ return;
34
+ }
35
+ if (!flushed.text || !deltaPath) {
36
+ return;
37
+ }
38
+ if (lastDeltaTemplate) {
39
+ const object = structuredClone(lastDeltaTemplate.object);
40
+ setByPath(object, deltaPath, flushed.text);
41
+ sink.write(serializeFrame(object, wireFormat, lastDeltaTemplate.original));
42
+ } else {
43
+ // No prior delta frame to model — fall back to a minimal synthesized frame.
44
+ sink.write(serializeFrame(buildPathObject(deltaPath, flushed.text), wireFormat, null));
45
+ }
46
+ }
17
47
 
18
48
  async function handleFrame(raw) {
19
49
  const frame = { raw, body: raw.trim() };
@@ -26,6 +56,16 @@ export async function inspectResponseStream({ source, sink, streaming, protector
26
56
  }
27
57
 
28
58
  const json = parsed.json;
59
+
60
+ // A delta-terminating frame: flush the held tail (as a valid delta frame)
61
+ // before emitting it, so the residual is correctly ordered.
62
+ if (flushOnType && flushOnType.values.includes(getByPath(json, flushOnType.path))) {
63
+ await flushHeldTail();
64
+ if (blocked) {
65
+ return;
66
+ }
67
+ }
68
+
29
69
  let deltaText = null;
30
70
  if (deltaPath) {
31
71
  const found = getByPath(json, deltaPath);
@@ -50,6 +90,8 @@ export async function inspectResponseStream({ source, sink, streaming, protector
50
90
  return;
51
91
  }
52
92
  setByPath(frameObject, deltaPath, pushed.text);
93
+ // Snapshot this frame's structure + wire wrapper as the flush template.
94
+ lastDeltaTemplate = { object: structuredClone(frameObject), original: frame };
53
95
  }
54
96
 
55
97
  sink.write(serializeFrame(frameObject, wireFormat, frame));
@@ -77,13 +119,8 @@ export async function inspectResponseStream({ source, sink, streaming, protector
77
119
  }
78
120
 
79
121
  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
- }
122
+ // Flush any remaining held tail (a stream that ended on a delta frame).
123
+ await flushHeldTail();
87
124
  }
88
125
 
89
126
  // The caller closes the sink AFTER recording the stream decision, so the
@@ -148,6 +185,31 @@ function parseFrame(frame, format) {
148
185
  function serializeFrame(json, format, original) {
149
186
  const body = JSON.stringify(json);
150
187
  if (format === "sse") {
188
+ // Preserve the original SSE field lines (`event:`, `id:`, `retry:`, `:`
189
+ // comments) and substitute only the data payload. Event-typed streams
190
+ // (Anthropic Messages) dispatch on the `event:` line, so dropping it would
191
+ // make the stream unconsumable. OpenAI-style frames carry only a `data:`
192
+ // line, so the output is byte-identical to `data: ${body}\n\n`.
193
+ if (original && typeof original.raw === "string") {
194
+ const lines = original.raw.replace(/\n+$/, "").split("\n");
195
+ const out = [];
196
+ let dataWritten = false;
197
+ for (const line of lines) {
198
+ if (line.startsWith("data:")) {
199
+ // Collapse any (multi-line) data payload into the single new body.
200
+ if (!dataWritten) {
201
+ out.push(`data: ${body}`);
202
+ dataWritten = true;
203
+ }
204
+ } else {
205
+ out.push(line);
206
+ }
207
+ }
208
+ if (!dataWritten) {
209
+ out.push(`data: ${body}`);
210
+ }
211
+ return `${out.join("\n")}\n\n`;
212
+ }
151
213
  return `data: ${body}\n\n`;
152
214
  }
153
215
  // NDJSON: preserve the original trailing newline style when available.