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
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from "node:fs/promises";
3
- import { readAuditSummary } from "../../audit/index.mjs";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { readAuditSummary, verifyAuditChain } from "../../audit/index.mjs";
4
4
  import { DEFAULT_PROXY_PORT, createHaechiProxy } from "../../proxy/index.mjs";
5
5
  import { signPolicyBundleFile, verifyPolicyBundleFile } from "../../policy-bundle/index.mjs";
6
6
  import { validatePluginManifestFile } from "../../plugin/index.mjs";
7
- import { runMcpStdioFilter } from "../../mcp-stdio/index.mjs";
7
+ import { runMcpStdioFilter, wrapMcpChild } from "../../mcp-stdio/index.mjs";
8
+ import { spawn } from "node:child_process";
8
9
  import { DEFAULT_CONFIG_PATH, createRuntime, isValidPort, loadConfig, writeDefaultConfig } from "../runtime.mjs";
9
10
 
10
11
  const [command, ...argv] = process.argv.slice(2);
11
12
 
13
+ async function main(command, argv) {
12
14
  try {
13
15
  switch (command) {
14
16
  case "init":
@@ -20,6 +22,12 @@ try {
20
22
  case "report":
21
23
  await reportCommand(argv);
22
24
  break;
25
+ case "audit-verify":
26
+ await auditVerifyCommand(argv);
27
+ break;
28
+ case "status":
29
+ await statusCommand(argv);
30
+ break;
23
31
  case "proxy":
24
32
  await proxyCommand(argv);
25
33
  break;
@@ -44,19 +52,26 @@ try {
44
52
  case "mcp-stdio":
45
53
  await mcpStdioCommand(argv);
46
54
  break;
55
+ case "mcp-wrap":
56
+ await mcpWrapCommand(argv);
57
+ break;
58
+ case "config":
59
+ printConfigGuide();
60
+ break;
47
61
  case "help":
48
62
  case "--help":
49
63
  case "-h":
50
64
  case undefined:
51
- printHelp();
65
+ printHelp(argv[0]);
52
66
  break;
53
67
  default:
54
- throw new Error(`Unknown command: ${command}`);
68
+ throw new Error(`Unknown command: ${command}. Run 'haechi help' for usage.`);
55
69
  }
56
70
  } catch (error) {
57
71
  console.error(`haechi: ${error.message}`);
58
72
  process.exitCode = process.exitCode || 1;
59
73
  }
74
+ }
60
75
 
61
76
  async function initCommand(argv) {
62
77
  const options = parseOptions(argv);
@@ -91,6 +106,7 @@ async function protectCommand(argv) {
91
106
  const result = await runtime.haechi.protectJson(input, {
92
107
  protocol: config.target.type,
93
108
  operation: "cli protect",
109
+ direction: "request",
94
110
  mode: effectiveMode
95
111
  });
96
112
 
@@ -123,6 +139,117 @@ async function reportCommand(argv) {
123
139
  });
124
140
  }
125
141
 
142
+ async function auditVerifyCommand(argv) {
143
+ const options = parseOptions(argv);
144
+ let auditPath = options.audit ?? options.path;
145
+ if (!auditPath) {
146
+ try {
147
+ auditPath = (await loadConfig(options.config ?? DEFAULT_CONFIG_PATH)).audit.path;
148
+ } catch {
149
+ auditPath = ".haechi/audit.jsonl";
150
+ }
151
+ }
152
+
153
+ const result = await verifyAuditChain(auditPath);
154
+ writeJson({
155
+ ok: result.valid,
156
+ command: "audit-verify",
157
+ auditPath,
158
+ result
159
+ });
160
+ if (!result.valid) {
161
+ process.exitCode = 4;
162
+ }
163
+ }
164
+
165
+ async function statusCommand(argv) {
166
+ const options = parseOptions(argv);
167
+ const configPath = options.config ?? DEFAULT_CONFIG_PATH;
168
+ const config = await loadConfig(configPath);
169
+ const effectiveMode = config.policy.mode ?? config.mode;
170
+ const enforced = !["dry-run", "report-only"].includes(effectiveMode);
171
+ const warnings = [];
172
+
173
+ if (!enforced) {
174
+ warnings.push(`policy mode is ${effectiveMode}: payloads are inspected and audited but NOT modified or blocked`);
175
+ }
176
+ if (!config.responseProtection.enabled) {
177
+ warnings.push("responseProtection.enabled is false: upstream responses are forwarded without inspection");
178
+ }
179
+ if (config.streaming.requestMode === "pass-through") {
180
+ warnings.push("streaming.requestMode is pass-through: streaming payloads are not protected");
181
+ }
182
+ if (config.tokenVault.revealPolicy !== "disabled") {
183
+ warnings.push(`tokenVault.revealPolicy is ${config.tokenVault.revealPolicy}: manual token reveal is enabled`);
184
+ }
185
+ if (config.tokenVault.detokenizeResponses && !config.responseProtection.enabled) {
186
+ warnings.push("tokenVault.detokenizeResponses is true but responseProtection.enabled is false: detokenization never runs");
187
+ }
188
+
189
+ const keys = {
190
+ provider: config.keys.provider,
191
+ keyFile: config.keys.keyFile,
192
+ exists: false,
193
+ permissions: null
194
+ };
195
+ try {
196
+ const info = await stat(config.keys.keyFile);
197
+ keys.exists = true;
198
+ keys.permissions = `0${(info.mode & 0o777).toString(8)}`;
199
+ if ((info.mode & 0o077) !== 0) {
200
+ warnings.push(`key file ${config.keys.keyFile} is group/world accessible (${keys.permissions}); expected 0600`);
201
+ }
202
+ } catch {
203
+ warnings.push(`key file ${config.keys.keyFile} does not exist; run haechi init`);
204
+ }
205
+
206
+ const audit = { path: config.audit.path, exists: false, chain: null };
207
+ try {
208
+ await stat(config.audit.path);
209
+ audit.exists = true;
210
+ audit.chain = await verifyAuditChain(config.audit.path);
211
+ if (!audit.chain.valid) {
212
+ warnings.push(`audit chain verification failed: ${audit.chain.reason}`);
213
+ }
214
+ } catch {
215
+ // No audit file yet is a normal pre-first-run state, not a warning.
216
+ }
217
+
218
+ writeJson({
219
+ ok: true,
220
+ command: "status",
221
+ configPath,
222
+ protection: {
223
+ policyMode: effectiveMode,
224
+ enforced,
225
+ responseProtection: {
226
+ enabled: config.responseProtection.enabled,
227
+ mode: config.responseProtection.mode,
228
+ failureMode: config.responseProtection.failureMode
229
+ },
230
+ streamingRequestMode: config.streaming.requestMode,
231
+ streamingResponseMode: config.streaming.responseMode
232
+ },
233
+ target: {
234
+ type: config.target.type,
235
+ adapter: config.target.adapter,
236
+ upstream: config.target.upstream
237
+ },
238
+ proxy: config.proxy,
239
+ tokenVault: {
240
+ revealPolicy: config.tokenVault.revealPolicy,
241
+ retentionDays: config.tokenVault.retentionDays,
242
+ deterministic: config.tokenVault.deterministic,
243
+ deterministicTypes: config.tokenVault.deterministicTypes,
244
+ detokenizeResponses: config.tokenVault.detokenizeResponses
245
+ },
246
+ privacyProfile: config.privacy.profile,
247
+ keys,
248
+ audit,
249
+ warnings
250
+ });
251
+ }
252
+
126
253
  async function proxyCommand(argv) {
127
254
  const options = parseOptions(argv);
128
255
  const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
@@ -282,6 +409,30 @@ async function mcpStdioCommand(argv) {
282
409
  await runMcpStdioFilter({ runtime });
283
410
  }
284
411
 
412
+ async function mcpWrapCommand(argv) {
413
+ const separator = argv.indexOf("--");
414
+ if (separator === -1 || !argv[separator + 1]) {
415
+ throw new Error("mcp-wrap requires a child command after --, e.g. haechi mcp-wrap -- npx some-mcp-server");
416
+ }
417
+ const options = parseOptions(argv.slice(0, separator));
418
+ const command = argv[separator + 1];
419
+ const commandArgs = argv.slice(separator + 2);
420
+
421
+ const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
422
+ const runtime = createRuntime(config);
423
+
424
+ const child = spawn(command, commandArgs, {
425
+ stdio: ["pipe", "pipe", "inherit"]
426
+ });
427
+
428
+ for (const signal of ["SIGINT", "SIGTERM"]) {
429
+ process.on(signal, () => child.kill(signal));
430
+ }
431
+
432
+ const { code } = await wrapMcpChild({ runtime, child });
433
+ process.exitCode = code;
434
+ }
435
+
285
436
  function parseOptions(argv) {
286
437
  const options = {};
287
438
  for (let index = 0; index < argv.length; index += 1) {
@@ -319,23 +470,161 @@ function parsePort(value) {
319
470
  return port;
320
471
  }
321
472
 
322
- function printHelp() {
323
- console.log(`Haechi MVP CLI
473
+ const COMMAND_HELP = {
474
+ init: {
475
+ usage: "haechi init [--config haechi.config.json] [--force]",
476
+ summary: "Create a local key, sample config, and audit path.",
477
+ detail: "Writes haechi.config.json and .haechi/dev.keys.json (0600). --force rotates the key (prior keys are retired, not deleted) and overwrites the config."
478
+ },
479
+ protect: {
480
+ usage: "haechi protect <input.json> [--config haechi.config.json]",
481
+ summary: "Inspect and protect a JSON payload, printing the result.",
482
+ detail: "Reads input.json, applies the policy, and prints the protected payload, audit id, and warnings. Exit 3 if the payload is blocked."
483
+ },
484
+ report: {
485
+ usage: "haechi report [--audit .haechi/audit.jsonl]",
486
+ summary: "Summarize audit events without raw payloads."
487
+ },
488
+ "audit-verify": {
489
+ usage: "haechi audit-verify [--audit .haechi/audit.jsonl] [--config haechi.config.json]",
490
+ summary: "Verify the audit hash chain; print validity, record count, and head hash.",
491
+ detail: "Exit 4 on a broken chain. The head hash is the value to anchor externally against tail truncation."
492
+ },
493
+ status: {
494
+ usage: "haechi status [--config haechi.config.json]",
495
+ summary: "Show what is and is not protected under the current config.",
496
+ detail: "Prints effective policy mode, response/streaming protection, target, token vault governance, key file permissions, audit chain status, and a consolidated warnings list."
497
+ },
498
+ proxy: {
499
+ usage: `haechi proxy [--config haechi.config.json] [--host 127.0.0.1] [--port ${DEFAULT_PROXY_PORT}] [--allow-remote-bind]`,
500
+ summary: "Run the local HTTP JSON proxy in front of an upstream LLM.",
501
+ detail: "Binds loopback by default; --allow-remote-bind is required (and must be a CLI flag, not config) to bind non-loopback hosts. There is no client auth yet — see 'haechi config'."
502
+ },
503
+ "policy-sign": {
504
+ usage: "haechi policy-sign <policy.json> [--config haechi.config.json] [--out policy.bundle.json]",
505
+ summary: "Sign a policy file into a verifiable bundle."
506
+ },
507
+ "policy-verify": {
508
+ usage: "haechi policy-verify <policy.bundle.json> [--config haechi.config.json]",
509
+ summary: "Verify a signed policy bundle against the configured key."
510
+ },
511
+ "token-reveal": {
512
+ usage: "haechi token-reveal <token> [--config haechi.config.json] [--allow-dev-reveal]",
513
+ summary: "Reveal a tokenized value (governed by tokenVault.revealPolicy; audited).",
514
+ detail: "Fails unless revealPolicy is local-dev or --allow-dev-reveal is passed."
515
+ },
516
+ "token-purge": {
517
+ usage: "haechi token-purge <token> [--config haechi.config.json]\n haechi token-purge --expired [--config haechi.config.json]",
518
+ summary: "Purge a specific token, or all expired tokens with --expired."
519
+ },
520
+ "token-export": {
521
+ usage: "haechi token-export [--config haechi.config.json] [--type email]",
522
+ summary: "Export token metadata (never plaintext), optionally filtered by type."
523
+ },
524
+ "plugin-validate": {
525
+ usage: "haechi plugin-validate <plugin-manifest.json>",
526
+ summary: "Validate a plugin manifest (manifest-only; dynamic runtime is rejected)."
527
+ },
528
+ "mcp-stdio": {
529
+ usage: "haechi mcp-stdio [--config haechi.config.json]",
530
+ summary: "Filter MCP JSON-RPC traffic on stdin/stdout (one direction)."
531
+ },
532
+ "mcp-wrap": {
533
+ usage: "haechi mcp-wrap [--config haechi.config.json] -- <command> [args...]",
534
+ summary: "Wrap an MCP server with bidirectional stdio protection.",
535
+ detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs."
536
+ },
537
+ config: {
538
+ usage: "haechi config",
539
+ summary: "Print the configuration guide (keys, defaults, common setups)."
540
+ }
541
+ };
542
+
543
+ function printHelp(topic) {
544
+ if (topic && COMMAND_HELP[topic]) {
545
+ const entry = COMMAND_HELP[topic];
546
+ console.log(`haechi ${topic} — ${entry.summary}\n\nUsage:\n ${entry.usage}${entry.detail ? `\n\n${entry.detail}` : ""}`);
547
+ return;
548
+ }
549
+
550
+ const order = [
551
+ "init", "protect", "report", "status", "audit-verify", "proxy",
552
+ "policy-sign", "policy-verify",
553
+ "token-reveal", "token-purge", "token-export",
554
+ "plugin-validate", "mcp-stdio", "mcp-wrap", "config"
555
+ ];
556
+ const lines = order.map((name) => ` ${name.padEnd(16)}${COMMAND_HELP[name].summary}`);
557
+ console.log(`Haechi — self-hosted AI context enforcement (developer preview)
324
558
 
325
559
  Usage:
326
- haechi init [--config haechi.config.json] [--force]
327
- haechi protect <input.json> [--config haechi.config.json]
328
- haechi report [--audit .haechi/audit.jsonl]
329
- haechi proxy [--config haechi.config.json] [--host 127.0.0.1] [--port ${DEFAULT_PROXY_PORT}] [--allow-remote-bind]
330
- haechi policy-sign <policy.json> [--config haechi.config.json] [--out policy.bundle.json]
331
- haechi policy-verify <policy.bundle.json> [--config haechi.config.json]
332
- haechi token-reveal <token> [--config haechi.config.json] [--allow-dev-reveal]
333
- haechi token-purge <token> [--config haechi.config.json]
334
- haechi token-purge --expired [--config haechi.config.json]
335
- haechi token-export [--config haechi.config.json] [--type email]
336
- haechi plugin-validate <plugin-manifest.json>
337
- haechi mcp-stdio [--config haechi.config.json]
338
-
339
- The default policy mode is dry-run. Change policy.mode to enforce to mutate or block payloads.
560
+ haechi <command> [options]
561
+ haechi help <command> show usage for one command
562
+
563
+ Commands:
564
+ ${lines.join("\n")}
565
+
566
+ Getting started:
567
+ haechi init write config + local key
568
+ haechi status see what is protected
569
+ haechi config configuration guide
570
+
571
+ The default policy mode is dry-run (detect + audit only). Set policy.mode to
572
+ "enforce" to transform or block. Run 'haechi config' for all settings.
573
+ `);
574
+ }
575
+
576
+ function printConfigGuide() {
577
+ console.log(`Haechi configuration guide
578
+
579
+ Config file: haechi.config.json (override with --config <path>); template at
580
+ haechi.config.example.json. All values are validated fail-closed — unknown or
581
+ malformed settings refuse to start. 'haechi status' prints the EFFECTIVE state.
582
+
583
+ Enforcement
584
+ mode / policy.mode dry-run | report-only | enforce (default dry-run)
585
+ dry-run/report-only detect + audit only.
586
+ policy.mode overrides mode.
587
+
588
+ Upstream + proxy
589
+ target.type llm-http | openai-compatible | vllm-openai |
590
+ ollama | llama-cpp (unknown = fail)
591
+ target.upstream the only upstream the proxy forwards to
592
+ proxy.host / proxy.port 127.0.0.1 / ${DEFAULT_PROXY_PORT}
593
+ non-loopback host needs --allow-remote-bind (CLI flag)
594
+
595
+ Response + streaming
596
+ responseProtection.enabled inspect upstream responses (default false)
597
+ responseProtection.failureMode fail-closed | allow (default fail-closed)
598
+ streaming.requestMode block | pass-through | inspect (default block)
599
+ inspect = stream-filter SSE/NDJSON responses
600
+ streaming.maxMatchBytes cross-frame match window (default 256)
601
+ limits.upstreamTimeoutMs upstream timeout in ms (default 120000)
602
+
603
+ Detection policy
604
+ policy.presets korean-pii, secrets-only, llm-redact,
605
+ strict-block, mcp-basic, local-inference, local-only
606
+ policy.defaultAction allow | redact | mask | tokenize | encrypt | block
607
+ policy.actions per-type overrides; merges may strengthen, not weaken
608
+ filters.customRules extra regex rules (ReDoS-screened)
609
+
610
+ Tokenization (model sees token, caller sees plaintext)
611
+ tokenVault.revealPolicy disabled | local-dev (manual reveal gate)
612
+ tokenVault.deterministic same value -> same token (default false)
613
+ tokenVault.detokenizeResponses restore request-issued tokens in the response
614
+ (needs responseProtection.enabled)
615
+
616
+ Privacy + MCP
617
+ privacy.profile kr-pipa | eu-gdpr | us-general | null
618
+ mcp.allowedMethods client-callable method allowlist
619
+
620
+ Binding beyond loopback (0.0.0.0):
621
+ haechi proxy --host 0.0.0.0 --allow-remote-bind
622
+ There is NO client auth yet (planned 0.6). Use only behind network controls:
623
+ bind 0.0.0.0 in a container and map -p 127.0.0.1:${DEFAULT_PROXY_PORT}:${DEFAULT_PROXY_PORT}, or front
624
+ it with a firewall/VPN/authenticating reverse proxy.
625
+
626
+ Full reference: docs/current/configuration.md
340
627
  `);
341
628
  }
629
+
630
+ await main(command, argv);
@@ -34,7 +34,9 @@ export function defaultConfig() {
34
34
  maxBytes: 1048576
35
35
  },
36
36
  streaming: {
37
- requestMode: "block"
37
+ requestMode: "block",
38
+ responseMode: "enforce",
39
+ maxMatchBytes: 256
38
40
  },
39
41
  limits: {
40
42
  maxRequestBytes: 1048576,
@@ -63,7 +65,10 @@ export function defaultConfig() {
63
65
  provider: "local",
64
66
  path: ".haechi/token-vault.json",
65
67
  revealPolicy: "disabled",
66
- retentionDays: 30
68
+ retentionDays: 30,
69
+ deterministic: false,
70
+ deterministicTypes: null,
71
+ detokenizeResponses: false
67
72
  },
68
73
  privacy: {
69
74
  profile: null
@@ -113,6 +118,8 @@ export function createRuntime(config, providers = {}) {
113
118
  cryptoProvider,
114
119
  revealPolicy: normalized.tokenVault.revealPolicy,
115
120
  retentionDays: normalized.tokenVault.retentionDays,
121
+ deterministic: normalized.tokenVault.deterministic,
122
+ deterministicTypes: normalized.tokenVault.deterministicTypes,
116
123
  auditSink
117
124
  });
118
125
  assertProvider("tokenVault", tokenVault, ["tokenize", "reveal", "purge"]);
@@ -233,6 +240,18 @@ export function normalizeConfig(config) {
233
240
  if (typeof merged.tokenVault.retentionDays !== "number" || merged.tokenVault.retentionDays < 1) {
234
241
  throw new Error("tokenVault.retentionDays must be a positive number");
235
242
  }
243
+ if (typeof merged.tokenVault.deterministic !== "boolean") {
244
+ throw new Error("tokenVault.deterministic must be boolean");
245
+ }
246
+ if (merged.tokenVault.deterministicTypes !== null
247
+ && (!Array.isArray(merged.tokenVault.deterministicTypes)
248
+ || merged.tokenVault.deterministicTypes.length === 0
249
+ || !merged.tokenVault.deterministicTypes.every((type) => typeof type === "string" && type.trim()))) {
250
+ throw new Error("tokenVault.deterministicTypes must be null or a non-empty array of type strings");
251
+ }
252
+ if (typeof merged.tokenVault.detokenizeResponses !== "boolean") {
253
+ throw new Error("tokenVault.detokenizeResponses must be boolean");
254
+ }
236
255
  if (!Array.isArray(merged.mcp.allowedMethods) || merged.mcp.allowedMethods.length === 0) {
237
256
  throw new Error("mcp.allowedMethods must be a non-empty array");
238
257
  }
@@ -254,9 +273,15 @@ export function normalizeConfig(config) {
254
273
  if (typeof merged.responseProtection.maxBytes !== "number" || merged.responseProtection.maxBytes < 1) {
255
274
  throw new Error("responseProtection.maxBytes must be a positive number");
256
275
  }
257
- if (!["block", "pass-through"].includes(merged.streaming.requestMode)) {
276
+ if (!["block", "pass-through", "inspect"].includes(merged.streaming.requestMode)) {
258
277
  throw new Error(`Invalid streaming.requestMode: ${merged.streaming.requestMode}`);
259
278
  }
279
+ if (!["dry-run", "report-only", "enforce"].includes(merged.streaming.responseMode)) {
280
+ throw new Error(`Invalid streaming.responseMode: ${merged.streaming.responseMode}`);
281
+ }
282
+ if (typeof merged.streaming.maxMatchBytes !== "number" || merged.streaming.maxMatchBytes < 1) {
283
+ throw new Error("streaming.maxMatchBytes must be a positive number");
284
+ }
260
285
  if (typeof merged.limits.maxRequestBytes !== "number" || merged.limits.maxRequestBytes < 1) {
261
286
  throw new Error("limits.maxRequestBytes must be a positive number");
262
287
  }
@@ -19,11 +19,15 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
19
19
 
20
20
  const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
21
21
  const blocked = enforced && decisions.some((decision) => decision.action === "block");
22
+ // Tokens issued or reused while protecting THIS payload; the proxy uses
23
+ // this request-scoped set to restore only these tokens in the response.
24
+ const issuedTokens = new Set();
22
25
  const protectedPayload = blocked ? null : await transformPayload(payload, detections, decisions, {
23
26
  context,
24
27
  cryptoProvider,
25
28
  tokenVault,
26
- enforced
29
+ enforced,
30
+ issuedTokens
27
31
  });
28
32
 
29
33
  const auditEvent = buildAuditEvent({
@@ -42,11 +46,121 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
42
46
  payload: protectedPayload,
43
47
  blocked,
44
48
  summary: summarize(detections, decisions),
45
- auditEvent
49
+ auditEvent,
50
+ issuedTokens: [...issuedTokens]
51
+ };
52
+ }
53
+
54
+ // Stateful protector for an incremental text stream (SSE/NDJSON deltas).
55
+ // Holds a bounded raw tail so a detection split across chunk boundaries is
56
+ // caught before the leading part is emitted. maxMatchBytes bounds the
57
+ // guarantee: a single match longer than it may still split across frames.
58
+ function createStreamProtector(context = {}) {
59
+ const effectiveMode = context.mode ?? mode;
60
+ const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
61
+ const maxMatchBytes = context.maxMatchBytes ?? 256;
62
+ const byType = {};
63
+ const byAction = {};
64
+ let detectionCount = 0;
65
+ let pending = "";
66
+
67
+ function tally(detections, decisions) {
68
+ detections.forEach((detection, index) => {
69
+ byType[detection.type] = (byType[detection.type] ?? 0) + 1;
70
+ const action = decisions[index]?.action ?? "unknown";
71
+ byAction[action] = (byAction[action] ?? 0) + 1;
72
+ detectionCount += 1;
73
+ });
74
+ }
75
+
76
+ async function decideAll(detections) {
77
+ const decisions = [];
78
+ for (const detection of detections) {
79
+ decisions.push(await policyEngine.decide({ detection, context, mode: effectiveMode }));
80
+ }
81
+ return decisions;
82
+ }
83
+
84
+ // Transform a complete, committed text segment.
85
+ async function transformSegment(text) {
86
+ const detections = await filterEngine.detect({
87
+ entries: collectStringEntries(text),
88
+ context
89
+ });
90
+ const decisions = await decideAll(detections);
91
+ tally(detections, decisions);
92
+ const blocked = enforced && decisions.some((decision) => decision.action === "block");
93
+ if (blocked) {
94
+ return { text: "", blocked: true };
95
+ }
96
+ if (!enforced || detections.length === 0) {
97
+ return { text, blocked: false };
98
+ }
99
+ const items = detections.map((detection, index) => ({ detection, decision: decisions[index] }));
100
+ const transformed = await transformString(text, items, { context, cryptoProvider, tokenVault, issuedTokens: null });
101
+ return { text: transformed, blocked: false };
102
+ }
103
+
104
+ return {
105
+ // Protect string leaves of a parsed frame OTHER than the incremental
106
+ // delta text (e.g. tool-call arguments). Returns the mutated object.
107
+ async protectFrameExtras(value) {
108
+ const detections = await filterEngine.detect({
109
+ entries: collectStringEntries(value),
110
+ context
111
+ });
112
+ if (detections.length === 0) {
113
+ return { value, blocked: false };
114
+ }
115
+ const decisions = await decideAll(detections);
116
+ tally(detections, decisions);
117
+ const blocked = enforced && decisions.some((decision) => decision.action === "block");
118
+ if (blocked) {
119
+ return { value: null, blocked: true };
120
+ }
121
+ if (!enforced) {
122
+ return { value, blocked: false };
123
+ }
124
+ const transformed = await transformPayload(value, detections, decisions, {
125
+ context, cryptoProvider, tokenVault, enforced
126
+ });
127
+ return { value: transformed, blocked: false };
128
+ },
129
+ // Append incremental text; return the portion safe to emit now.
130
+ async push(text) {
131
+ pending += text;
132
+ const detections = await filterEngine.detect({
133
+ entries: collectStringEntries(pending),
134
+ context
135
+ });
136
+ let commit = Math.max(0, pending.length - maxMatchBytes);
137
+ const straddlers = detections.filter((detection) => detection.end > commit);
138
+ if (straddlers.length > 0) {
139
+ commit = Math.min(commit, ...straddlers.map((detection) => detection.start));
140
+ }
141
+ if (commit <= 0) {
142
+ return { text: "", blocked: false };
143
+ }
144
+ const head = pending.slice(0, commit);
145
+ pending = pending.slice(commit);
146
+ return transformSegment(head);
147
+ },
148
+ // Drain the held tail at end of stream (no more cross-frame risk).
149
+ async flush() {
150
+ const tail = pending;
151
+ pending = "";
152
+ if (!tail) {
153
+ return { text: "", blocked: false };
154
+ }
155
+ return transformSegment(tail);
156
+ },
157
+ summary() {
158
+ return { detectionCount, byType, byAction };
159
+ }
46
160
  };
47
161
  }
48
162
 
49
- return { protectJson };
163
+ return { protectJson, createStreamProtector };
50
164
  }
51
165
 
52
166
  export function collectStringEntries(value, path = []) {
@@ -127,7 +241,7 @@ export function summarize(detections, decisions) {
127
241
  };
128
242
  }
129
243
 
130
- async function transformPayload(payload, detections, decisions, { context, cryptoProvider, tokenVault, enforced }) {
244
+ async function transformPayload(payload, detections, decisions, { context, cryptoProvider, tokenVault, enforced, issuedTokens = null }) {
131
245
  if (!enforced || detections.length === 0) {
132
246
  return structuredClone(payload);
133
247
  }
@@ -155,7 +269,7 @@ async function transformPayload(payload, detections, decisions, { context, crypt
155
269
  if (typeof original !== "number") {
156
270
  continue;
157
271
  }
158
- const transformed = await transformString(String(original), items, { context, cryptoProvider, tokenVault });
272
+ const transformed = await transformString(String(original), items, { context, cryptoProvider, tokenVault, issuedTokens });
159
273
  if (transformed !== String(original)) {
160
274
  setByPath(output, path, transformed);
161
275
  }
@@ -164,7 +278,7 @@ async function transformPayload(payload, detections, decisions, { context, crypt
164
278
  if (typeof original !== "string") {
165
279
  continue;
166
280
  }
167
- const transformed = await transformString(original, items, { context, cryptoProvider, tokenVault });
281
+ const transformed = await transformString(original, items, { context, cryptoProvider, tokenVault, issuedTokens });
168
282
  setByPath(output, path, transformed);
169
283
  }
170
284
 
@@ -179,7 +293,7 @@ async function transformPayload(payload, detections, decisions, { context, crypt
179
293
  || !Object.prototype.hasOwnProperty.call(parent, originalKey)) {
180
294
  continue;
181
295
  }
182
- const transformedKey = await transformString(String(originalKey), items, { context, cryptoProvider, tokenVault });
296
+ const transformedKey = await transformString(String(originalKey), items, { context, cryptoProvider, tokenVault, issuedTokens });
183
297
  if (transformedKey === originalKey) {
184
298
  continue;
185
299
  }
@@ -197,7 +311,7 @@ async function transformPayload(payload, detections, decisions, { context, crypt
197
311
  return output;
198
312
  }
199
313
 
200
- async function transformString(value, items, { context, cryptoProvider, tokenVault }) {
314
+ async function transformString(value, items, { context, cryptoProvider, tokenVault, issuedTokens = null }) {
201
315
  const sorted = items
202
316
  .filter(({ decision }) => decision.action !== "allow" && decision.action !== "block")
203
317
  .sort((left, right) => left.detection.start - right.detection.start);
@@ -212,7 +326,7 @@ async function transformString(value, items, { context, cryptoProvider, tokenVau
212
326
 
213
327
  output += value.slice(cursor, detection.start);
214
328
  const segment = value.slice(detection.start, detection.end);
215
- output += await replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault });
329
+ output += await replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault, issuedTokens });
216
330
  cursor = detection.end;
217
331
  }
218
332
 
@@ -220,7 +334,7 @@ async function transformString(value, items, { context, cryptoProvider, tokenVau
220
334
  return output;
221
335
  }
222
336
 
223
- async function replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault }) {
337
+ async function replacementFor(segment, detection, decision, { context, cryptoProvider, tokenVault, issuedTokens = null }) {
224
338
  switch (decision.action) {
225
339
  case "redact":
226
340
  return `[REDACTED:${detection.type}]`;
@@ -237,6 +351,7 @@ async function replacementFor(segment, detection, decision, { context, cryptoPro
237
351
  ruleId: detection.ruleId
238
352
  }
239
353
  });
354
+ issuedTokens?.add(result.token);
240
355
  return `[TOKEN:${result.token}]`;
241
356
  }
242
357
  return `[TOKEN:${detection.type}:${shortHash(segment)}]`;
@@ -263,6 +378,9 @@ function buildAuditEvent({ context, mode, enforced, blocked, payload, detections
263
378
  timestamp: new Date().toISOString(),
264
379
  protocol: context.protocol ?? "custom",
265
380
  operation: context.operation ?? "protect",
381
+ // Reserved for 0.6 auth: hard null so unvalidated identity objects cannot
382
+ // reach the audit log before the PII-safe hashing contract exists.
383
+ identity: null,
266
384
  mode,
267
385
  enforced,
268
386
  blocked,