haechi 0.4.0 → 0.6.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.
@@ -0,0 +1,170 @@
1
+ // Built-in bearer authentication and the authProvider contract.
2
+ //
3
+ // Tokens are never stored in plaintext: the store keeps a keyed-HMAC hash
4
+ // (domain-separated, never a bare hash) plus PII-safe metadata. The plaintext
5
+ // token is shown once at creation. Identity objects are PII-safe by
6
+ // construction — subject/issuer are keyed HMACs, never raw values.
7
+
8
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
9
+ import { dirname } from "node:path";
10
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
11
+
12
+ const TOKEN_DOMAIN = "haechi:auth:token:v1";
13
+ const IDENTITY_DOMAIN = "haechi:identity:hash:v1";
14
+ const TOKEN_PREFIX = "hae_";
15
+ const DEFAULT_ALLOWED_LABEL_KEYS = ["team", "env", "tier", "role"];
16
+ const VALID_IDENTITY_TYPES = new Set(["user", "service", "agent"]);
17
+ const MAX_LABEL_VALUE_LENGTH = 64;
18
+
19
+ export async function readAuthStore(path) {
20
+ try {
21
+ const parsed = JSON.parse(await readFile(path, "utf8"));
22
+ return { version: parsed.version ?? 1, tokens: parsed.tokens ?? [] };
23
+ } catch (error) {
24
+ if (error.code === "ENOENT") {
25
+ return { version: 1, tokens: [] };
26
+ }
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ async function writeAuthStore(path, store) {
32
+ await mkdir(dirname(path), { recursive: true });
33
+ const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
34
+ await writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
35
+ await rename(tempPath, path);
36
+ }
37
+
38
+ export function validateLabels(labels, allowedLabelKeys = DEFAULT_ALLOWED_LABEL_KEYS) {
39
+ for (const [key, value] of Object.entries(labels)) {
40
+ if (!allowedLabelKeys.includes(key)) {
41
+ throw new Error(`Label key not allowed: ${key} (allowed: ${allowedLabelKeys.join(", ") || "none"})`);
42
+ }
43
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_LABEL_VALUE_LENGTH) {
44
+ throw new Error(`Label value for ${key} must be a non-empty string up to ${MAX_LABEL_VALUE_LENGTH} chars`);
45
+ }
46
+ }
47
+ return labels;
48
+ }
49
+
50
+ export async function addToken({ path, cryptoProvider, type, scopes = [], labels = {}, allowedLabelKeys = DEFAULT_ALLOWED_LABEL_KEYS }) {
51
+ if (!VALID_IDENTITY_TYPES.has(type)) {
52
+ throw new Error(`Invalid token type: ${type} (expected user | service | agent)`);
53
+ }
54
+ if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string" && scope.trim())) {
55
+ throw new Error("scopes must be an array of non-empty strings");
56
+ }
57
+ validateLabels(labels, allowedLabelKeys);
58
+
59
+ const token = `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
60
+ const tokenHash = await cryptoProvider.hmac({ data: token, domain: TOKEN_DOMAIN });
61
+ const record = {
62
+ id: `tok_auth_${randomUUID().slice(0, 8)}`,
63
+ tokenHash,
64
+ type,
65
+ scopes,
66
+ labels,
67
+ createdAt: new Date().toISOString(),
68
+ disabled: false
69
+ };
70
+
71
+ const store = await readAuthStore(path);
72
+ store.tokens.push(record);
73
+ await writeAuthStore(path, store);
74
+
75
+ // The plaintext token is returned to the caller for one-time display only.
76
+ return { token, record: publicRecord(record) };
77
+ }
78
+
79
+ export async function listTokens(path) {
80
+ const store = await readAuthStore(path);
81
+ return store.tokens.map(publicRecord);
82
+ }
83
+
84
+ export async function revokeToken({ path, id }) {
85
+ const store = await readAuthStore(path);
86
+ const record = store.tokens.find((entry) => entry.id === id);
87
+ if (!record) {
88
+ throw new Error(`Unknown token id: ${id}`);
89
+ }
90
+ const changed = !record.disabled;
91
+ record.disabled = true;
92
+ await writeAuthStore(path, store);
93
+ return { id, revoked: changed };
94
+ }
95
+
96
+ function publicRecord(record) {
97
+ // Never expose the token or its hash.
98
+ return {
99
+ id: record.id,
100
+ type: record.type,
101
+ scopes: record.scopes,
102
+ labels: record.labels,
103
+ createdAt: record.createdAt,
104
+ disabled: Boolean(record.disabled)
105
+ };
106
+ }
107
+
108
+ export async function buildIdentity(record, cryptoProvider) {
109
+ return {
110
+ id: record.id,
111
+ type: record.type,
112
+ subjectHash: await cryptoProvider.hmac({ data: record.id, domain: IDENTITY_DOMAIN }),
113
+ issuerHash: await cryptoProvider.hmac({ data: "bearer-local", domain: IDENTITY_DOMAIN }),
114
+ provider: "bearer",
115
+ scopes: record.scopes ?? [],
116
+ labels: record.labels ?? {}
117
+ };
118
+ }
119
+
120
+ function bearerTokenFromRequest(request) {
121
+ const header = request?.headers?.authorization ?? request?.headers?.Authorization;
122
+ if (typeof header !== "string") {
123
+ return null;
124
+ }
125
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
126
+ return match ? match[1].trim() : null;
127
+ }
128
+
129
+ function constantTimeHashMatch(candidateHash, storedHash) {
130
+ const a = Buffer.from(candidateHash, "utf8");
131
+ const b = Buffer.from(storedHash, "utf8");
132
+ return a.length === b.length && timingSafeEqual(a, b);
133
+ }
134
+
135
+ // authProvider contract: authenticate(request) -> identity | null. Fails closed
136
+ // (null/deny) for a missing/invalid/disabled token; throws are treated as deny
137
+ // by the caller.
138
+ export function createBearerAuthProvider({ path, cryptoProvider }) {
139
+ if (!path) {
140
+ throw new Error("Bearer auth provider requires a store path");
141
+ }
142
+ if (typeof cryptoProvider?.hmac !== "function") {
143
+ throw new Error("Bearer auth provider requires a cryptoProvider with hmac()");
144
+ }
145
+ return {
146
+ id: "haechi.auth.bearer",
147
+ async authenticate(request) {
148
+ const token = bearerTokenFromRequest(request);
149
+ if (!token) {
150
+ return null;
151
+ }
152
+ const candidateHash = await cryptoProvider.hmac({ data: token, domain: TOKEN_DOMAIN });
153
+ const store = await readAuthStore(path);
154
+ let matched = null;
155
+ // Scan every record (no early return) so timing does not reveal which
156
+ // token matched.
157
+ for (const record of store.tokens) {
158
+ if (!record.disabled && constantTimeHashMatch(candidateHash, record.tokenHash)) {
159
+ matched = record;
160
+ }
161
+ }
162
+ if (!matched) {
163
+ return null;
164
+ }
165
+ return buildIdentity(matched, cryptoProvider);
166
+ }
167
+ };
168
+ }
169
+
170
+ export { DEFAULT_ALLOWED_LABEL_KEYS };
@@ -5,11 +5,14 @@ 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
7
  import { runMcpStdioFilter, wrapMcpChild } from "../../mcp-stdio/index.mjs";
8
+ import { addToken, listTokens, revokeToken } from "../../auth/index.mjs";
9
+ import { createLocalCryptoProvider } from "../../crypto/index.mjs";
8
10
  import { spawn } from "node:child_process";
9
11
  import { DEFAULT_CONFIG_PATH, createRuntime, isValidPort, loadConfig, writeDefaultConfig } from "../runtime.mjs";
10
12
 
11
13
  const [command, ...argv] = process.argv.slice(2);
12
14
 
15
+ async function main(command, argv) {
13
16
  try {
14
17
  switch (command) {
15
18
  case "init":
@@ -54,19 +57,26 @@ try {
54
57
  case "mcp-wrap":
55
58
  await mcpWrapCommand(argv);
56
59
  break;
60
+ case "auth":
61
+ await authCommand(argv);
62
+ break;
63
+ case "config":
64
+ printConfigGuide();
65
+ break;
57
66
  case "help":
58
67
  case "--help":
59
68
  case "-h":
60
69
  case undefined:
61
- printHelp();
70
+ printHelp(argv[0]);
62
71
  break;
63
72
  default:
64
- throw new Error(`Unknown command: ${command}`);
73
+ throw new Error(`Unknown command: ${command}. Run 'haechi help' for usage.`);
65
74
  }
66
75
  } catch (error) {
67
76
  console.error(`haechi: ${error.message}`);
68
77
  process.exitCode = process.exitCode || 1;
69
78
  }
79
+ }
70
80
 
71
81
  async function initCommand(argv) {
72
82
  const options = parseOptions(argv);
@@ -222,7 +232,8 @@ async function statusCommand(argv) {
222
232
  mode: config.responseProtection.mode,
223
233
  failureMode: config.responseProtection.failureMode
224
234
  },
225
- streamingRequestMode: config.streaming.requestMode
235
+ streamingRequestMode: config.streaming.requestMode,
236
+ streamingResponseMode: config.streaming.responseMode
226
237
  },
227
238
  target: {
228
239
  type: config.target.type,
@@ -396,6 +407,76 @@ async function pluginValidateCommand(argv) {
396
407
  }
397
408
  }
398
409
 
410
+ async function authCommand(argv) {
411
+ const [sub, ...rest] = argv;
412
+ const options = parseOptions(rest);
413
+ const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
414
+ if (config.keys.provider !== "local") {
415
+ throw new Error("haechi auth requires keys.provider local (the bearer store is hashed with the local key)");
416
+ }
417
+ const cryptoProvider = createLocalCryptoProvider({ keyFile: config.keys.keyFile });
418
+ const storePath = config.auth.store;
419
+
420
+ switch (sub) {
421
+ case "add": {
422
+ if (!options.type || options.type === true) {
423
+ throw new Error("auth add requires --type user|service|agent");
424
+ }
425
+ const { token, record } = await addToken({
426
+ path: storePath,
427
+ cryptoProvider,
428
+ type: options.type,
429
+ scopes: asList(options.scope),
430
+ labels: asLabels(options.label),
431
+ allowedLabelKeys: config.auth.allowedLabelKeys
432
+ });
433
+ writeJson({
434
+ ok: true,
435
+ command: "auth add",
436
+ id: record.id,
437
+ type: record.type,
438
+ scopes: record.scopes,
439
+ labels: record.labels,
440
+ token,
441
+ warning: "This token is shown only once. Store it now; it is not recoverable."
442
+ });
443
+ return;
444
+ }
445
+ case "list":
446
+ writeJson({ ok: true, command: "auth list", tokens: await listTokens(storePath) });
447
+ return;
448
+ case "revoke": {
449
+ const [id] = rest;
450
+ if (!id || id.startsWith("--")) {
451
+ throw new Error("auth revoke requires a token id");
452
+ }
453
+ writeJson({ ok: true, command: "auth revoke", result: await revokeToken({ path: storePath, id }) });
454
+ return;
455
+ }
456
+ default:
457
+ throw new Error("auth requires a subcommand: add | list | revoke");
458
+ }
459
+ }
460
+
461
+ function asList(value) {
462
+ if (!value || value === true) {
463
+ return [];
464
+ }
465
+ return Array.isArray(value) ? value : [value];
466
+ }
467
+
468
+ function asLabels(value) {
469
+ const labels = {};
470
+ for (const entry of asList(value)) {
471
+ const index = entry.indexOf("=");
472
+ if (index === -1) {
473
+ throw new Error(`Invalid --label (expected key=value): ${entry}`);
474
+ }
475
+ labels[entry.slice(0, index)] = entry.slice(index + 1);
476
+ }
477
+ return labels;
478
+ }
479
+
399
480
  async function mcpStdioCommand(argv) {
400
481
  const options = parseOptions(argv);
401
482
  const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
@@ -436,12 +517,17 @@ function parseOptions(argv) {
436
517
  }
437
518
  const key = arg.slice(2);
438
519
  const next = argv[index + 1];
439
- if (!next || next.startsWith("--")) {
440
- options[key] = true;
441
- continue;
520
+ const value = (!next || next.startsWith("--")) ? true : next;
521
+ if (value !== true) {
522
+ index += 1;
523
+ }
524
+ // Repeated flags accumulate into an array (e.g. --scope a --scope b);
525
+ // a single occurrence stays scalar for backward compatibility.
526
+ if (Object.prototype.hasOwnProperty.call(options, key)) {
527
+ options[key] = Array.isArray(options[key]) ? [...options[key], value] : [options[key], value];
528
+ } else {
529
+ options[key] = value;
442
530
  }
443
- options[key] = next;
444
- index += 1;
445
531
  }
446
532
  return options;
447
533
  }
@@ -464,26 +550,166 @@ function parsePort(value) {
464
550
  return port;
465
551
  }
466
552
 
467
- function printHelp() {
468
- console.log(`Haechi MVP CLI
553
+ const COMMAND_HELP = {
554
+ init: {
555
+ usage: "haechi init [--config haechi.config.json] [--force]",
556
+ summary: "Create a local key, sample config, and audit path.",
557
+ 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."
558
+ },
559
+ protect: {
560
+ usage: "haechi protect <input.json> [--config haechi.config.json]",
561
+ summary: "Inspect and protect a JSON payload, printing the result.",
562
+ detail: "Reads input.json, applies the policy, and prints the protected payload, audit id, and warnings. Exit 3 if the payload is blocked."
563
+ },
564
+ report: {
565
+ usage: "haechi report [--audit .haechi/audit.jsonl]",
566
+ summary: "Summarize audit events without raw payloads."
567
+ },
568
+ "audit-verify": {
569
+ usage: "haechi audit-verify [--audit .haechi/audit.jsonl] [--config haechi.config.json]",
570
+ summary: "Verify the audit hash chain; print validity, record count, and head hash.",
571
+ detail: "Exit 4 on a broken chain. The head hash is the value to anchor externally against tail truncation."
572
+ },
573
+ status: {
574
+ usage: "haechi status [--config haechi.config.json]",
575
+ summary: "Show what is and is not protected under the current config.",
576
+ detail: "Prints effective policy mode, response/streaming protection, target, token vault governance, key file permissions, audit chain status, and a consolidated warnings list."
577
+ },
578
+ proxy: {
579
+ usage: `haechi proxy [--config haechi.config.json] [--host 127.0.0.1] [--port ${DEFAULT_PROXY_PORT}] [--allow-remote-bind]`,
580
+ summary: "Run the local HTTP JSON proxy in front of an upstream LLM.",
581
+ 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'."
582
+ },
583
+ "policy-sign": {
584
+ usage: "haechi policy-sign <policy.json> [--config haechi.config.json] [--out policy.bundle.json]",
585
+ summary: "Sign a policy file into a verifiable bundle."
586
+ },
587
+ "policy-verify": {
588
+ usage: "haechi policy-verify <policy.bundle.json> [--config haechi.config.json]",
589
+ summary: "Verify a signed policy bundle against the configured key."
590
+ },
591
+ "token-reveal": {
592
+ usage: "haechi token-reveal <token> [--config haechi.config.json] [--allow-dev-reveal]",
593
+ summary: "Reveal a tokenized value (governed by tokenVault.revealPolicy; audited).",
594
+ detail: "Fails unless revealPolicy is local-dev or --allow-dev-reveal is passed."
595
+ },
596
+ "token-purge": {
597
+ usage: "haechi token-purge <token> [--config haechi.config.json]\n haechi token-purge --expired [--config haechi.config.json]",
598
+ summary: "Purge a specific token, or all expired tokens with --expired."
599
+ },
600
+ "token-export": {
601
+ usage: "haechi token-export [--config haechi.config.json] [--type email]",
602
+ summary: "Export token metadata (never plaintext), optionally filtered by type."
603
+ },
604
+ "plugin-validate": {
605
+ usage: "haechi plugin-validate <plugin-manifest.json>",
606
+ summary: "Validate a plugin manifest (manifest-only; dynamic runtime is rejected)."
607
+ },
608
+ "mcp-stdio": {
609
+ usage: "haechi mcp-stdio [--config haechi.config.json]",
610
+ summary: "Filter MCP JSON-RPC traffic on stdin/stdout (one direction)."
611
+ },
612
+ "mcp-wrap": {
613
+ usage: "haechi mcp-wrap [--config haechi.config.json] -- <command> [args...]",
614
+ summary: "Wrap an MCP server with bidirectional stdio protection.",
615
+ detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs."
616
+ },
617
+ auth: {
618
+ usage: "haechi auth add --type user|service|agent [--scope k:v ...] [--label k=v ...]\n haechi auth list [--config haechi.config.json]\n haechi auth revoke <id> [--config haechi.config.json]",
619
+ summary: "Manage built-in bearer tokens (separate store, hashed).",
620
+ detail: "Tokens are stored hashed in auth.store (default .haechi/auth.json, 0600). `add` prints the plaintext token once — it cannot be recovered. `list` never reveals tokens; `revoke` disables one by id."
621
+ },
622
+ config: {
623
+ usage: "haechi config",
624
+ summary: "Print the configuration guide (keys, defaults, common setups)."
625
+ }
626
+ };
627
+
628
+ function printHelp(topic) {
629
+ if (topic && COMMAND_HELP[topic]) {
630
+ const entry = COMMAND_HELP[topic];
631
+ console.log(`haechi ${topic} — ${entry.summary}\n\nUsage:\n ${entry.usage}${entry.detail ? `\n\n${entry.detail}` : ""}`);
632
+ return;
633
+ }
634
+
635
+ const order = [
636
+ "init", "protect", "report", "status", "audit-verify", "proxy",
637
+ "policy-sign", "policy-verify",
638
+ "token-reveal", "token-purge", "token-export",
639
+ "plugin-validate", "mcp-stdio", "mcp-wrap", "auth", "config"
640
+ ];
641
+ const lines = order.map((name) => ` ${name.padEnd(16)}${COMMAND_HELP[name].summary}`);
642
+ console.log(`Haechi — self-hosted AI context enforcement (developer preview)
469
643
 
470
644
  Usage:
471
- haechi init [--config haechi.config.json] [--force]
472
- haechi protect <input.json> [--config haechi.config.json]
473
- haechi report [--audit .haechi/audit.jsonl]
474
- haechi audit-verify [--audit .haechi/audit.jsonl] [--config haechi.config.json]
475
- haechi status [--config haechi.config.json]
476
- haechi proxy [--config haechi.config.json] [--host 127.0.0.1] [--port ${DEFAULT_PROXY_PORT}] [--allow-remote-bind]
477
- haechi policy-sign <policy.json> [--config haechi.config.json] [--out policy.bundle.json]
478
- haechi policy-verify <policy.bundle.json> [--config haechi.config.json]
479
- haechi token-reveal <token> [--config haechi.config.json] [--allow-dev-reveal]
480
- haechi token-purge <token> [--config haechi.config.json]
481
- haechi token-purge --expired [--config haechi.config.json]
482
- haechi token-export [--config haechi.config.json] [--type email]
483
- haechi plugin-validate <plugin-manifest.json>
484
- haechi mcp-stdio [--config haechi.config.json]
485
- haechi mcp-wrap [--config haechi.config.json] -- <command> [args...]
486
-
487
- The default policy mode is dry-run. Change policy.mode to enforce to mutate or block payloads.
645
+ haechi <command> [options]
646
+ haechi help <command> show usage for one command
647
+
648
+ Commands:
649
+ ${lines.join("\n")}
650
+
651
+ Getting started:
652
+ haechi init write config + local key
653
+ haechi status see what is protected
654
+ haechi config configuration guide
655
+
656
+ The default policy mode is dry-run (detect + audit only). Set policy.mode to
657
+ "enforce" to transform or block. Run 'haechi config' for all settings.
488
658
  `);
489
659
  }
660
+
661
+ function printConfigGuide() {
662
+ console.log(`Haechi configuration guide
663
+
664
+ Config file: haechi.config.json (override with --config <path>); template at
665
+ haechi.config.example.json. All values are validated fail-closed — unknown or
666
+ malformed settings refuse to start. 'haechi status' prints the EFFECTIVE state.
667
+
668
+ Enforcement
669
+ mode / policy.mode dry-run | report-only | enforce (default dry-run)
670
+ dry-run/report-only detect + audit only.
671
+ policy.mode overrides mode.
672
+
673
+ Upstream + proxy
674
+ target.type llm-http | openai-compatible | vllm-openai |
675
+ ollama | llama-cpp (unknown = fail)
676
+ target.upstream the only upstream the proxy forwards to
677
+ proxy.host / proxy.port 127.0.0.1 / ${DEFAULT_PROXY_PORT}
678
+ non-loopback host needs --allow-remote-bind (CLI flag)
679
+
680
+ Response + streaming
681
+ responseProtection.enabled inspect upstream responses (default false)
682
+ responseProtection.failureMode fail-closed | allow (default fail-closed)
683
+ streaming.requestMode block | pass-through | inspect (default block)
684
+ inspect = stream-filter SSE/NDJSON responses
685
+ streaming.maxMatchBytes cross-frame match window (default 256)
686
+ limits.upstreamTimeoutMs upstream timeout in ms (default 120000)
687
+
688
+ Detection policy
689
+ policy.presets korean-pii, secrets-only, llm-redact,
690
+ strict-block, mcp-basic, local-inference, local-only
691
+ policy.defaultAction allow | redact | mask | tokenize | encrypt | block
692
+ policy.actions per-type overrides; merges may strengthen, not weaken
693
+ filters.customRules extra regex rules (ReDoS-screened)
694
+
695
+ Tokenization (model sees token, caller sees plaintext)
696
+ tokenVault.revealPolicy disabled | local-dev (manual reveal gate)
697
+ tokenVault.deterministic same value -> same token (default false)
698
+ tokenVault.detokenizeResponses restore request-issued tokens in the response
699
+ (needs responseProtection.enabled)
700
+
701
+ Privacy + MCP
702
+ privacy.profile kr-pipa | eu-gdpr | us-general | null
703
+ mcp.allowedMethods client-callable method allowlist
704
+
705
+ Binding beyond loopback (0.0.0.0):
706
+ haechi proxy --host 0.0.0.0 --allow-remote-bind
707
+ There is NO client auth yet (planned 0.6). Use only behind network controls:
708
+ bind 0.0.0.0 in a container and map -p 127.0.0.1:${DEFAULT_PROXY_PORT}:${DEFAULT_PROXY_PORT}, or front
709
+ it with a firewall/VPN/authenticating reverse proxy.
710
+
711
+ Full reference: docs/current/configuration.md
712
+ `);
713
+ }
714
+
715
+ await main(command, argv);