haechi 0.5.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  - Status: Draft 0.1
4
4
  - Date: 2026-06-10
5
- - Target version: 0.4.0
5
+ - Target version: 0.6.0
6
6
 
7
7
  ## 1. Assets Under Protection
8
8
 
@@ -47,6 +47,8 @@ The primary assets Haechi protects are:
47
47
  | Hung upstream | Proxy connection exhaustion | `limits.upstreamTimeoutMs` default 120 s; 504 fail on timeout |
48
48
  | Signing/encryption key conflation | Key separation violation | Policy bundle signing key isolated as a domain-separated derived key |
49
49
  | JSON number / object key concealment | Undetected non-string leaves such as card numbers | Number leaves and object keys included in detection/transform scope |
50
+ | Unauthenticated multi-client access | Any local process uses the upstream / token round-trip | Optional bearer auth (`auth.provider: bearer`); missing/invalid → 401 before body read; per-identity rate limit and model allowlist |
51
+ | Raw credentials/identity in audit | Token or subject leak through the audit log | Tokens stored only as keyed-HMAC hashes; identity subject/issuer are keyed HMAC; `auth_denied` records no token |
50
52
  | Token round-trip restoring foreign tokens | Cross-client/request plaintext recovery | Detokenization is opt-in (`detokenizeResponses`) and request-scoped: only tokens issued while protecting the same request are restored |
51
53
  | Indirect prompt injection in tool results/responses | Agent manipulation via planted instructions | Response-direction heuristics, report-only by default (`injection` action `allow`); escalation is an explicit policy choice. Not a complete defense |
52
54
 
@@ -61,6 +61,16 @@
61
61
  "privacy": {
62
62
  "profile": null
63
63
  },
64
+ "auth": {
65
+ "provider": "none",
66
+ "store": ".haechi/auth.json",
67
+ "allowedLabelKeys": [
68
+ "team",
69
+ "env",
70
+ "tier",
71
+ "role"
72
+ ]
73
+ },
64
74
  "mcp": {
65
75
  "allowedMethods": [
66
76
  "initialize",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haechi",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Experimental developer preview for self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -45,7 +45,8 @@
45
45
  "./proxy": "./packages/proxy/index.mjs",
46
46
  "./runtime": "./packages/cli/runtime.mjs",
47
47
  "./token-vault": "./packages/token-vault/index.mjs",
48
- "./stream-filter": "./packages/stream-filter/index.mjs"
48
+ "./stream-filter": "./packages/stream-filter/index.mjs",
49
+ "./auth": "./packages/auth/index.mjs"
49
50
  },
50
51
  "files": [
51
52
  "README.md",
@@ -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,6 +5,8 @@ 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
 
@@ -55,6 +57,9 @@ try {
55
57
  case "mcp-wrap":
56
58
  await mcpWrapCommand(argv);
57
59
  break;
60
+ case "auth":
61
+ await authCommand(argv);
62
+ break;
58
63
  case "config":
59
64
  printConfigGuide();
60
65
  break;
@@ -402,6 +407,76 @@ async function pluginValidateCommand(argv) {
402
407
  }
403
408
  }
404
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
+
405
480
  async function mcpStdioCommand(argv) {
406
481
  const options = parseOptions(argv);
407
482
  const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
@@ -442,12 +517,17 @@ function parseOptions(argv) {
442
517
  }
443
518
  const key = arg.slice(2);
444
519
  const next = argv[index + 1];
445
- if (!next || next.startsWith("--")) {
446
- options[key] = true;
447
- 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;
448
530
  }
449
- options[key] = next;
450
- index += 1;
451
531
  }
452
532
  return options;
453
533
  }
@@ -534,6 +614,11 @@ const COMMAND_HELP = {
534
614
  summary: "Wrap an MCP server with bidirectional stdio protection.",
535
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."
536
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
+ },
537
622
  config: {
538
623
  usage: "haechi config",
539
624
  summary: "Print the configuration guide (keys, defaults, common setups)."
@@ -551,7 +636,7 @@ function printHelp(topic) {
551
636
  "init", "protect", "report", "status", "audit-verify", "proxy",
552
637
  "policy-sign", "policy-verify",
553
638
  "token-reveal", "token-purge", "token-export",
554
- "plugin-validate", "mcp-stdio", "mcp-wrap", "config"
639
+ "plugin-validate", "mcp-stdio", "mcp-wrap", "auth", "config"
555
640
  ];
556
641
  const lines = order.map((name) => ` ${name.padEnd(16)}${COMMAND_HELP[name].summary}`);
557
642
  console.log(`Haechi — self-hosted AI context enforcement (developer preview)
@@ -2,13 +2,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { createHaechi } from "../core/index.mjs";
4
4
  import { createDefaultFilterEngine } from "../filter/index.mjs";
5
- import { buildPolicy, createPolicyEngine } from "../policy/index.mjs";
5
+ import { createPolicyProfiles } from "../policy/index.mjs";
6
6
  import { createLocalCryptoProvider, initLocalKeyFile } from "../crypto/index.mjs";
7
7
  import { createJsonlAuditSink } from "../audit/index.mjs";
8
8
  import { createLocalTokenVault } from "../token-vault/index.mjs";
9
9
  import { loadVerifiedPolicyBundleFileSync } from "../policy-bundle/index.mjs";
10
10
  import { createProtocolAdapter } from "../protocol-adapters/index.mjs";
11
11
  import { applyPrivacyProfile, getPrivacyProfile } from "../privacy-profiles/index.mjs";
12
+ import { createBearerAuthProvider } from "../auth/index.mjs";
12
13
  import { DEFAULT_PROXY_PORT } from "../proxy/index.mjs";
13
14
 
14
15
  export const DEFAULT_CONFIG_PATH = "haechi.config.json";
@@ -73,6 +74,11 @@ export function defaultConfig() {
73
74
  privacy: {
74
75
  profile: null
75
76
  },
77
+ auth: {
78
+ provider: "none",
79
+ store: ".haechi/auth.json",
80
+ allowedLabelKeys: ["team", "env", "tier", "role"]
81
+ },
76
82
  mcp: {
77
83
  allowedMethods: ["initialize", "tools/call", "resources/read", "prompts/get"],
78
84
  protectParams: true,
@@ -135,19 +141,25 @@ export function createRuntime(config, providers = {}) {
135
141
  ...normalized.policy,
136
142
  mode: normalized.policy.mode ?? normalized.mode
137
143
  };
138
- const policy = buildPolicy(normalized.privacy.profile
139
- ? applyPrivacyProfile(policySource, normalized.privacy.profile)
140
- : policySource);
144
+ const policyProfiles = createPolicyProfiles(policySource, {
145
+ transform: (source) => normalized.privacy.profile
146
+ ? applyPrivacyProfile(source, normalized.privacy.profile)
147
+ : source
148
+ });
141
149
 
142
150
  const filterEngine = providers.filterEngine ?? createDefaultFilterEngine(normalized.filters);
143
151
  assertProvider("filterEngine", filterEngine, ["detect"]);
144
- const policyEngine = providers.policyEngine ?? createPolicyEngine(policy);
152
+ const policyEngine = providers.policyEngine ?? policyProfiles.base.policyEngine;
145
153
  assertProvider("policyEngine", policyEngine, ["decide"]);
146
154
 
155
+ const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider);
156
+
147
157
  return {
148
158
  config: normalized,
149
159
  tokenVault,
150
160
  auditSink,
161
+ authProvider,
162
+ policyProfiles,
151
163
  protocolAdapter: createProtocolAdapter(normalized.target),
152
164
  haechi: createHaechi({
153
165
  mode: normalized.mode,
@@ -212,6 +224,11 @@ export function normalizeConfig(config) {
212
224
  ...defaultConfig().privacy,
213
225
  ...(config.privacy ?? {})
214
226
  },
227
+ auth: {
228
+ ...defaultConfig().auth,
229
+ ...(config.auth ?? {}),
230
+ allowedLabelKeys: config.auth?.allowedLabelKeys ?? defaultConfig().auth.allowedLabelKeys
231
+ },
215
232
  mcp: {
216
233
  ...defaultConfig().mcp,
217
234
  ...(config.mcp ?? {}),
@@ -288,6 +305,17 @@ export function normalizeConfig(config) {
288
305
  if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
289
306
  throw new Error("limits.upstreamTimeoutMs must be a positive number");
290
307
  }
308
+ validatePolicyExtras(merged.policy);
309
+ if (!["none", "bearer", "external"].includes(merged.auth.provider)) {
310
+ throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
311
+ }
312
+ if (typeof merged.auth.store !== "string" || !merged.auth.store.trim()) {
313
+ throw new Error("auth.store must be a non-empty string");
314
+ }
315
+ if (!Array.isArray(merged.auth.allowedLabelKeys)
316
+ || !merged.auth.allowedLabelKeys.every((key) => typeof key === "string" && key.trim())) {
317
+ throw new Error("auth.allowedLabelKeys must be an array of non-empty strings");
318
+ }
291
319
  createProtocolAdapter(merged.target);
292
320
  return merged;
293
321
  }
@@ -296,6 +324,76 @@ export function isValidPort(port) {
296
324
  return Number.isInteger(port) && port >= 0 && port <= 65535;
297
325
  }
298
326
 
327
+ function validatePolicyExtras(policy) {
328
+ if (policy.modelAllowlist !== undefined) {
329
+ assertModelAllowlist(policy.modelAllowlist, "policy.modelAllowlist");
330
+ }
331
+ if (policy.rate !== undefined) {
332
+ assertRate(policy.rate, "policy.rate");
333
+ }
334
+ if (policy.profiles !== undefined) {
335
+ if (typeof policy.profiles !== "object" || policy.profiles === null || Array.isArray(policy.profiles)) {
336
+ throw new Error("policy.profiles must be an object of named profiles");
337
+ }
338
+ for (const [name, profile] of Object.entries(policy.profiles)) {
339
+ if (typeof profile !== "object" || profile === null || Array.isArray(profile)) {
340
+ throw new Error(`policy.profiles.${name} must be an object`);
341
+ }
342
+ if (profile.modelAllowlist !== undefined) {
343
+ assertModelAllowlist(profile.modelAllowlist, `policy.profiles.${name}.modelAllowlist`);
344
+ }
345
+ if (profile.rate !== undefined) {
346
+ assertRate(profile.rate, `policy.profiles.${name}.rate`);
347
+ }
348
+ }
349
+ }
350
+ if (policy.profileBinding !== undefined) {
351
+ const binding = policy.profileBinding;
352
+ if (typeof binding !== "object" || binding === null || Array.isArray(binding)) {
353
+ throw new Error("policy.profileBinding must be an object");
354
+ }
355
+ if (typeof binding.default !== "string" || !binding.default.trim()) {
356
+ throw new Error("policy.profileBinding.default must be a profile name");
357
+ }
358
+ for (const field of ["byScope", "byLabel"]) {
359
+ if (binding[field] !== undefined
360
+ && (typeof binding[field] !== "object" || binding[field] === null || Array.isArray(binding[field]))) {
361
+ throw new Error(`policy.profileBinding.${field} must be an object`);
362
+ }
363
+ }
364
+ }
365
+ }
366
+
367
+ function assertModelAllowlist(value, label) {
368
+ if (!Array.isArray(value) || !value.every((model) => typeof model === "string" && model.trim())) {
369
+ throw new Error(`${label} must be an array of non-empty strings`);
370
+ }
371
+ }
372
+
373
+ function assertRate(value, label) {
374
+ if (typeof value !== "object" || value === null
375
+ || typeof value.requestsPerMinute !== "number" || value.requestsPerMinute < 1) {
376
+ throw new Error(`${label}.requestsPerMinute must be a positive number`);
377
+ }
378
+ }
379
+
380
+ function resolveAuthProvider(config, providers, cryptoProvider) {
381
+ if (config.auth.provider === "external") {
382
+ if (typeof providers.authProvider?.authenticate !== "function") {
383
+ throw new Error("auth.provider external requires createRuntime(config, { authProvider })");
384
+ }
385
+ return providers.authProvider;
386
+ }
387
+ if (providers.authProvider) {
388
+ // An injected provider overrides the built-in selection.
389
+ return providers.authProvider;
390
+ }
391
+ if (config.auth.provider === "bearer") {
392
+ return createBearerAuthProvider({ path: config.auth.store, cryptoProvider });
393
+ }
394
+ return null;
395
+ }
396
+
299
397
  function createConfiguredCryptoProvider(config) {
300
398
  if (config.keys.provider === "external") {
301
399
  throw new Error("keys.provider external requires createRuntime(config, { cryptoProvider })");
@@ -7,14 +7,19 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
7
7
  throw new Error("Haechi requires filterEngine, policyEngine, cryptoProvider, and auditSink");
8
8
  }
9
9
 
10
- async function protectJson(payload, context = {}) {
10
+ async function protectJson(payload, rawContext = {}) {
11
+ // A per-request policy engine (a named profile selected from identity)
12
+ // overrides the default. It is a control object, NOT data: strip it before
13
+ // anything downstream (tokenize AAD, audit) sees the context.
14
+ const { policyEngine: contextEngine, ...context } = rawContext;
11
15
  const effectiveMode = context.mode ?? mode;
16
+ const engine = contextEngine ?? policyEngine;
12
17
  const entries = collectStringEntries(payload);
13
18
  const detections = await filterEngine.detect({ entries, context });
14
19
  const decisions = [];
15
20
 
16
21
  for (const detection of detections) {
17
- decisions.push(await policyEngine.decide({ detection, context, mode: effectiveMode }));
22
+ decisions.push(await engine.decide({ detection, context, mode: effectiveMode }));
18
23
  }
19
24
 
20
25
  const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
@@ -55,8 +60,12 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
55
60
  // Holds a bounded raw tail so a detection split across chunk boundaries is
56
61
  // caught before the leading part is emitted. maxMatchBytes bounds the
57
62
  // guarantee: a single match longer than it may still split across frames.
58
- function createStreamProtector(context = {}) {
63
+ function createStreamProtector(rawContext = {}) {
64
+ // Strip the control-object policy engine from the data context (see
65
+ // protectJson) so it cannot leak into tokenize AAD or audit.
66
+ const { policyEngine: contextEngine, ...context } = rawContext;
59
67
  const effectiveMode = context.mode ?? mode;
68
+ const engine = contextEngine ?? policyEngine;
60
69
  const enforced = !NO_ENFORCE_MODES.has(effectiveMode);
61
70
  const maxMatchBytes = context.maxMatchBytes ?? 256;
62
71
  const byType = {};
@@ -76,7 +85,7 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
76
85
  async function decideAll(detections) {
77
86
  const decisions = [];
78
87
  for (const detection of detections) {
79
- decisions.push(await policyEngine.decide({ detection, context, mode: effectiveMode }));
88
+ decisions.push(await engine.decide({ detection, context, mode: effectiveMode }));
80
89
  }
81
90
  return decisions;
82
91
  }
@@ -378,9 +387,11 @@ function buildAuditEvent({ context, mode, enforced, blocked, payload, detections
378
387
  timestamp: new Date().toISOString(),
379
388
  protocol: context.protocol ?? "custom",
380
389
  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,
390
+ // PII-safe identity built by the auth layer (subject/issuer are keyed
391
+ // HMACs); null when no auth is configured. `profile` is the resolved
392
+ // policy profile name (or null).
393
+ identity: context.identity ?? null,
394
+ profile: context.profile ?? null,
384
395
  mode,
385
396
  enforced,
386
397
  blocked,
@@ -130,6 +130,88 @@ export function createPolicyEngine(policy) {
130
130
  };
131
131
  }
132
132
 
133
+ // Compiles the base policy plus every named profile into ready policy engines
134
+ // and a resolver that maps an identity to one. A profile inherits the base
135
+ // policy's presets/actions and overrides on top (so a profile need only state
136
+ // what differs). `transform` (e.g. applyPrivacyProfile) is applied to each
137
+ // compiled policy source before buildPolicy.
138
+ export function createPolicyProfiles(policyConfig = {}, { transform } = {}) {
139
+ const { profiles = {}, profileBinding = null, ...baseSource } = policyConfig;
140
+ const apply = (source) => (transform ? transform(source) : source);
141
+
142
+ const baseEngine = createPolicyEngine(buildPolicy(apply(baseSource)));
143
+ const profileNames = Object.keys(profiles);
144
+ const engines = new Map();
145
+
146
+ for (const name of profileNames) {
147
+ const override = profiles[name] ?? {};
148
+ const merged = {
149
+ ...baseSource,
150
+ ...override,
151
+ // Profile presets replace the base presets when given; actions merge over
152
+ // the base via buildPolicy's strengthen-only rules.
153
+ actions: { ...(baseSource.actions ?? {}), ...(override.actions ?? {}) },
154
+ modelAllowlist: override.modelAllowlist ?? baseSource.modelAllowlist,
155
+ rate: override.rate ?? baseSource.rate
156
+ };
157
+ engines.set(name, {
158
+ policyEngine: createPolicyEngine(buildPolicy(apply(merged))),
159
+ modelAllowlist: merged.modelAllowlist ?? null,
160
+ rate: merged.rate ?? null
161
+ });
162
+ }
163
+
164
+ if (profileBinding) {
165
+ if (!profileBinding.default || !engines.has(profileBinding.default)) {
166
+ throw new Error("policy.profileBinding.default must name a declared profile");
167
+ }
168
+ for (const map of [profileBinding.byScope ?? {}, profileBinding.byLabel ?? {}]) {
169
+ for (const [key, target] of Object.entries(map)) {
170
+ if (!engines.has(target)) {
171
+ throw new Error(`policy.profileBinding maps ${key} to unknown profile: ${target}`);
172
+ }
173
+ }
174
+ }
175
+ } else if (profileNames.length > 0) {
176
+ throw new Error("policy.profiles requires policy.profileBinding with a default");
177
+ }
178
+
179
+ const base = {
180
+ policyEngine: baseEngine,
181
+ modelAllowlist: baseSource.modelAllowlist ?? null,
182
+ rate: baseSource.rate ?? null
183
+ };
184
+
185
+ return {
186
+ base,
187
+ hasProfiles: profileNames.length > 0,
188
+ // Resolve identity → { profile, policyEngine, modelAllowlist, rate }.
189
+ // Order: scope match → label match → default. Without profiles or identity,
190
+ // the base policy applies.
191
+ resolve(identity) {
192
+ if (!profileBinding) {
193
+ return { profile: null, ...base };
194
+ }
195
+ if (identity) {
196
+ for (const scope of identity.scopes ?? []) {
197
+ const name = profileBinding.byScope?.[scope];
198
+ if (name) {
199
+ return { profile: name, ...engines.get(name) };
200
+ }
201
+ }
202
+ for (const [key, value] of Object.entries(identity.labels ?? {})) {
203
+ const name = profileBinding.byLabel?.[`${key}=${value}`];
204
+ if (name) {
205
+ return { profile: name, ...engines.get(name) };
206
+ }
207
+ }
208
+ }
209
+ const fallback = profileBinding.default;
210
+ return { profile: fallback, ...engines.get(fallback) };
211
+ }
212
+ };
213
+ }
214
+
133
215
  export function validatePolicy(policy) {
134
216
  if (!policy || typeof policy !== "object") {
135
217
  throw new Error("Policy must be an object");