haechi 0.4.0 → 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.
- package/README.ko.md +227 -0
- package/README.md +13 -4
- package/docs/README.md +3 -6
- package/docs/current/api-stability.ko.md +2 -1
- package/docs/current/api-stability.md +1 -0
- package/docs/current/configuration.ko.md +210 -0
- package/docs/current/configuration.md +210 -0
- package/docs/current/release-0.5-implementation-scope.ko.md +69 -0
- package/docs/current/release-0.5-implementation-scope.md +69 -0
- package/docs/current/release-process.ko.md +2 -2
- package/docs/current/release-process.md +2 -2
- package/docs/current/risk-register-release-gate.ko.md +2 -2
- package/docs/current/risk-register-release-gate.md +2 -2
- package/docs/current/threat-model.ko.md +6 -4
- package/docs/current/threat-model.md +5 -3
- package/haechi.config.example.json +3 -1
- package/package.json +3 -2
- package/packages/cli/bin/haechi.mjs +163 -22
- package/packages/cli/runtime.mjs +10 -2
- package/packages/core/index.mjs +110 -1
- package/packages/protocol-adapters/index.mjs +33 -14
- package/packages/proxy/index.mjs +108 -1
- package/packages/stream-filter/index.mjs +194 -0
|
@@ -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
|
+
}
|