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.
- package/README.ko.md +227 -0
- package/README.md +126 -1
- package/docs/README.md +3 -6
- package/docs/current/api-stability.ko.md +11 -4
- package/docs/current/api-stability.md +11 -4
- package/docs/current/configuration.ko.md +210 -0
- package/docs/current/configuration.md +210 -0
- package/docs/current/release-0.3.2-hardening-scope.ko.md +2 -1
- package/docs/current/release-0.3.2-hardening-scope.md +2 -1
- package/docs/current/release-0.4-implementation-scope.ko.md +2 -1
- package/docs/current/release-0.4-implementation-scope.md +2 -1
- 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 +14 -4
- package/docs/current/release-process.md +14 -4
- package/docs/current/risk-register-release-gate.ko.md +11 -11
- package/docs/current/risk-register-release-gate.md +12 -12
- package/docs/current/threat-model.ko.md +8 -4
- package/docs/current/threat-model.md +8 -4
- package/haechi.config.example.json +7 -2
- package/package.json +8 -2
- package/packages/audit/index.mjs +3 -1
- package/packages/cli/bin/haechi.mjs +310 -21
- package/packages/cli/runtime.mjs +28 -3
- package/packages/core/index.mjs +128 -10
- package/packages/crypto/index.mjs +13 -1
- package/packages/filter/index.mjs +52 -3
- package/packages/mcp-stdio/index.mjs +103 -22
- package/packages/policy/index.mjs +6 -0
- package/packages/protocol-adapters/index.mjs +33 -14
- package/packages/proxy/index.mjs +149 -4
- package/packages/stream-filter/index.mjs +194 -0
- 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
|
-
|
|
323
|
-
|
|
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
|
|
327
|
-
haechi
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
haechi
|
|
334
|
-
haechi
|
|
335
|
-
haechi
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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);
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -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
|
}
|
package/packages/core/index.mjs
CHANGED
|
@@ -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,
|