haechi 0.5.0 → 0.7.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,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";
@@ -59,7 +60,12 @@ export function defaultConfig() {
59
60
  },
60
61
  audit: {
61
62
  sink: "jsonl",
62
- path: ".haechi/audit.jsonl"
63
+ path: ".haechi/audit.jsonl",
64
+ anchor: {
65
+ mode: "none",
66
+ path: ".haechi/audit.anchor.jsonl",
67
+ everyRecords: 1
68
+ }
63
69
  },
64
70
  tokenVault: {
65
71
  provider: "local",
@@ -73,6 +79,11 @@ export function defaultConfig() {
73
79
  privacy: {
74
80
  profile: null
75
81
  },
82
+ auth: {
83
+ provider: "none",
84
+ store: ".haechi/auth.json",
85
+ allowedLabelKeys: ["team", "env", "tier", "role"]
86
+ },
76
87
  mcp: {
77
88
  allowedMethods: ["initialize", "tools/call", "resources/read", "prompts/get"],
78
89
  protectParams: true,
@@ -111,7 +122,18 @@ export function createRuntime(config, providers = {}) {
111
122
  const normalized = normalizeConfig(config);
112
123
  const cryptoProvider = providers.cryptoProvider ?? createConfiguredCryptoProvider(normalized);
113
124
  assertProvider("cryptoProvider", cryptoProvider, ["encrypt", "decrypt"]);
114
- const auditSink = providers.auditSink ?? createJsonlAuditSink({ path: normalized.audit.path });
125
+ // hmac is only required by features that use it (bearer auth, deterministic
126
+ // tokenization). An encrypt-only external provider is valid otherwise; fail
127
+ // closed at construction rather than deep in a request if a needing feature
128
+ // is configured without it.
129
+ if (typeof cryptoProvider.hmac !== "function"
130
+ && (normalized.auth.provider === "bearer" || normalized.tokenVault.deterministic)) {
131
+ throw new Error("cryptoProvider must implement hmac() for bearer auth / deterministic tokenization");
132
+ }
133
+ const auditSink = providers.auditSink ?? createJsonlAuditSink({
134
+ path: normalized.audit.path,
135
+ anchor: normalized.audit.anchor
136
+ });
115
137
  assertProvider("auditSink", auditSink, ["record"]);
116
138
  const tokenVault = providers.tokenVault ?? createLocalTokenVault({
117
139
  path: normalized.tokenVault.path,
@@ -135,19 +157,25 @@ export function createRuntime(config, providers = {}) {
135
157
  ...normalized.policy,
136
158
  mode: normalized.policy.mode ?? normalized.mode
137
159
  };
138
- const policy = buildPolicy(normalized.privacy.profile
139
- ? applyPrivacyProfile(policySource, normalized.privacy.profile)
140
- : policySource);
160
+ const policyProfiles = createPolicyProfiles(policySource, {
161
+ transform: (source) => normalized.privacy.profile
162
+ ? applyPrivacyProfile(source, normalized.privacy.profile)
163
+ : source
164
+ });
141
165
 
142
166
  const filterEngine = providers.filterEngine ?? createDefaultFilterEngine(normalized.filters);
143
167
  assertProvider("filterEngine", filterEngine, ["detect"]);
144
- const policyEngine = providers.policyEngine ?? createPolicyEngine(policy);
168
+ const policyEngine = providers.policyEngine ?? policyProfiles.base.policyEngine;
145
169
  assertProvider("policyEngine", policyEngine, ["decide"]);
146
170
 
171
+ const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider);
172
+
147
173
  return {
148
174
  config: normalized,
149
175
  tokenVault,
150
176
  auditSink,
177
+ authProvider,
178
+ policyProfiles,
151
179
  protocolAdapter: createProtocolAdapter(normalized.target),
152
180
  haechi: createHaechi({
153
181
  mode: normalized.mode,
@@ -202,7 +230,11 @@ export function normalizeConfig(config) {
202
230
  },
203
231
  audit: {
204
232
  ...defaultConfig().audit,
205
- ...(config.audit ?? {})
233
+ ...(config.audit ?? {}),
234
+ anchor: {
235
+ ...defaultConfig().audit.anchor,
236
+ ...(config.audit?.anchor ?? {})
237
+ }
206
238
  },
207
239
  tokenVault: {
208
240
  ...defaultConfig().tokenVault,
@@ -212,6 +244,11 @@ export function normalizeConfig(config) {
212
244
  ...defaultConfig().privacy,
213
245
  ...(config.privacy ?? {})
214
246
  },
247
+ auth: {
248
+ ...defaultConfig().auth,
249
+ ...(config.auth ?? {}),
250
+ allowedLabelKeys: config.auth?.allowedLabelKeys ?? defaultConfig().auth.allowedLabelKeys
251
+ },
215
252
  mcp: {
216
253
  ...defaultConfig().mcp,
217
254
  ...(config.mcp ?? {}),
@@ -231,6 +268,16 @@ export function normalizeConfig(config) {
231
268
  if (merged.audit.sink !== "jsonl") {
232
269
  throw new Error("Current implementation only supports jsonl audit sink");
233
270
  }
271
+ if (!["none", "file", "stdout"].includes(merged.audit.anchor.mode)) {
272
+ throw new Error(`Invalid audit.anchor.mode: ${merged.audit.anchor.mode}`);
273
+ }
274
+ if (merged.audit.anchor.mode === "file"
275
+ && (typeof merged.audit.anchor.path !== "string" || !merged.audit.anchor.path.trim())) {
276
+ throw new Error("audit.anchor.mode 'file' requires audit.anchor.path");
277
+ }
278
+ if (!Number.isInteger(merged.audit.anchor.everyRecords) || merged.audit.anchor.everyRecords < 1) {
279
+ throw new Error("audit.anchor.everyRecords must be a positive integer");
280
+ }
234
281
  if (merged.tokenVault.provider !== "local") {
235
282
  throw new Error("0.2 only supports local token vault provider");
236
283
  }
@@ -288,6 +335,17 @@ export function normalizeConfig(config) {
288
335
  if (typeof merged.limits.upstreamTimeoutMs !== "number" || merged.limits.upstreamTimeoutMs < 1) {
289
336
  throw new Error("limits.upstreamTimeoutMs must be a positive number");
290
337
  }
338
+ validatePolicyExtras(merged.policy);
339
+ if (!["none", "bearer", "external"].includes(merged.auth.provider)) {
340
+ throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
341
+ }
342
+ if (typeof merged.auth.store !== "string" || !merged.auth.store.trim()) {
343
+ throw new Error("auth.store must be a non-empty string");
344
+ }
345
+ if (!Array.isArray(merged.auth.allowedLabelKeys)
346
+ || !merged.auth.allowedLabelKeys.every((key) => typeof key === "string" && key.trim())) {
347
+ throw new Error("auth.allowedLabelKeys must be an array of non-empty strings");
348
+ }
291
349
  createProtocolAdapter(merged.target);
292
350
  return merged;
293
351
  }
@@ -296,6 +354,76 @@ export function isValidPort(port) {
296
354
  return Number.isInteger(port) && port >= 0 && port <= 65535;
297
355
  }
298
356
 
357
+ function validatePolicyExtras(policy) {
358
+ if (policy.modelAllowlist !== undefined) {
359
+ assertModelAllowlist(policy.modelAllowlist, "policy.modelAllowlist");
360
+ }
361
+ if (policy.rate !== undefined) {
362
+ assertRate(policy.rate, "policy.rate");
363
+ }
364
+ if (policy.profiles !== undefined) {
365
+ if (typeof policy.profiles !== "object" || policy.profiles === null || Array.isArray(policy.profiles)) {
366
+ throw new Error("policy.profiles must be an object of named profiles");
367
+ }
368
+ for (const [name, profile] of Object.entries(policy.profiles)) {
369
+ if (typeof profile !== "object" || profile === null || Array.isArray(profile)) {
370
+ throw new Error(`policy.profiles.${name} must be an object`);
371
+ }
372
+ if (profile.modelAllowlist !== undefined) {
373
+ assertModelAllowlist(profile.modelAllowlist, `policy.profiles.${name}.modelAllowlist`);
374
+ }
375
+ if (profile.rate !== undefined) {
376
+ assertRate(profile.rate, `policy.profiles.${name}.rate`);
377
+ }
378
+ }
379
+ }
380
+ if (policy.profileBinding !== undefined) {
381
+ const binding = policy.profileBinding;
382
+ if (typeof binding !== "object" || binding === null || Array.isArray(binding)) {
383
+ throw new Error("policy.profileBinding must be an object");
384
+ }
385
+ if (typeof binding.default !== "string" || !binding.default.trim()) {
386
+ throw new Error("policy.profileBinding.default must be a profile name");
387
+ }
388
+ for (const field of ["byScope", "byLabel"]) {
389
+ if (binding[field] !== undefined
390
+ && (typeof binding[field] !== "object" || binding[field] === null || Array.isArray(binding[field]))) {
391
+ throw new Error(`policy.profileBinding.${field} must be an object`);
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ function assertModelAllowlist(value, label) {
398
+ if (!Array.isArray(value) || !value.every((model) => typeof model === "string" && model.trim())) {
399
+ throw new Error(`${label} must be an array of non-empty strings`);
400
+ }
401
+ }
402
+
403
+ function assertRate(value, label) {
404
+ if (typeof value !== "object" || value === null
405
+ || typeof value.requestsPerMinute !== "number" || value.requestsPerMinute < 1) {
406
+ throw new Error(`${label}.requestsPerMinute must be a positive number`);
407
+ }
408
+ }
409
+
410
+ function resolveAuthProvider(config, providers, cryptoProvider) {
411
+ if (config.auth.provider === "external") {
412
+ if (typeof providers.authProvider?.authenticate !== "function") {
413
+ throw new Error("auth.provider external requires createRuntime(config, { authProvider })");
414
+ }
415
+ return providers.authProvider;
416
+ }
417
+ if (providers.authProvider) {
418
+ // An injected provider overrides the built-in selection.
419
+ return providers.authProvider;
420
+ }
421
+ if (config.auth.provider === "bearer") {
422
+ return createBearerAuthProvider({ path: config.auth.store, cryptoProvider });
423
+ }
424
+ return null;
425
+ }
426
+
299
427
  function createConfiguredCryptoProvider(config) {
300
428
  if (config.keys.provider === "external") {
301
429
  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,
@@ -139,6 +139,113 @@ export async function initLocalKeyFile(keyFile, { force = false } = {}) {
139
139
  return { created: true, keyFile, rotated: retiredKeys.length > 0 };
140
140
  }
141
141
 
142
+ // Conformance suite for any cryptoProvider used via keys.provider: external.
143
+ // Adapter authors (e.g. a KMS satellite) run this to self-test against the
144
+ // contract. encrypt/decrypt are always required; hmac is required for
145
+ // tokenization, auth, deterministic tokens, and policy bundles — pass
146
+ // { requireHmac: false } for an encrypt-only provider.
147
+ export async function assertCryptoProviderConformance(provider, { requireHmac = true } = {}) {
148
+ const failures = [];
149
+ const check = async (name, fn) => {
150
+ try {
151
+ await fn();
152
+ } catch (error) {
153
+ failures.push(`${name}: ${error.message}`);
154
+ }
155
+ };
156
+ const assert = (condition, message) => {
157
+ if (!condition) {
158
+ throw new Error(message);
159
+ }
160
+ };
161
+
162
+ if (typeof provider?.encrypt !== "function" || typeof provider?.decrypt !== "function") {
163
+ throw new Error("cryptoProvider must implement encrypt() and decrypt()");
164
+ }
165
+
166
+ const plaintext = `conformance-${randomBytes(8).toString("hex")}@example.com`;
167
+ const aad = { purpose: "conformance", path: "messages[0].content", type: "email" };
168
+
169
+ const other = `conformance-${randomBytes(8).toString("hex")}@example.org`;
170
+
171
+ await check("encrypt/decrypt round-trip", async () => {
172
+ const envelope = await provider.encrypt({ plaintext, aad });
173
+ assert(envelope && typeof envelope === "object", "encrypt must return an envelope object");
174
+ assert(envelope.kid, "envelope must carry a key id (kid)");
175
+ assert(envelope.aadHash, "envelope must carry an aadHash");
176
+ const back = await provider.decrypt({ envelope, aad });
177
+ assert(back === plaintext, "decrypt did not return the original plaintext");
178
+ // A second, distinct plaintext rules out a decrypt that returns a fixed value.
179
+ const back2 = await provider.decrypt({ envelope: await provider.encrypt({ plaintext: other, aad }), aad });
180
+ assert(back2 === other, "decrypt did not return the second plaintext (fixed/garbage output)");
181
+ });
182
+
183
+ await check("decrypt rejects a different AAD", async () => {
184
+ const envelope = await provider.encrypt({ plaintext, aad });
185
+ let rejected = false;
186
+ try {
187
+ await provider.decrypt({ envelope, aad: { ...aad, type: "phone" } });
188
+ } catch {
189
+ rejected = true;
190
+ }
191
+ assert(rejected, "decrypt accepted a mismatched AAD (no AAD binding)");
192
+ });
193
+
194
+ await check("decrypt rejects tampered ciphertext (real AEAD authentication)", async () => {
195
+ const envelope = await provider.encrypt({ plaintext, aad });
196
+ if (typeof envelope.ct !== "string" || envelope.ct.length === 0) {
197
+ return; // provider uses a non-ct envelope shape; the AAD check above still applies
198
+ }
199
+ // Flip a byte of the ciphertext; a real AEAD provider fails the auth tag.
200
+ const buf = Buffer.from(envelope.ct, "base64url");
201
+ buf[0] ^= 0xff;
202
+ let rejected = false;
203
+ try {
204
+ await provider.decrypt({ envelope: { ...envelope, ct: buf.toString("base64url") }, aad });
205
+ } catch {
206
+ rejected = true;
207
+ }
208
+ assert(rejected, "decrypt accepted tampered ciphertext (no AEAD authentication)");
209
+ });
210
+
211
+ if (requireHmac) {
212
+ if (typeof provider.hmac !== "function") {
213
+ failures.push("hmac: provider does not implement hmac() (required for tokenization/auth/bundles)");
214
+ } else {
215
+ await check("hmac is deterministic and data-dependent", async () => {
216
+ const a = await provider.hmac({ data: "x", domain: "haechi:conformance:v1" });
217
+ const b = await provider.hmac({ data: "x", domain: "haechi:conformance:v1" });
218
+ assert(typeof a === "string" && a.length > 0, "hmac must return a non-empty string");
219
+ assert(a === b, "hmac is not deterministic for the same (data, domain)");
220
+ // Different data MUST give different output — else tokens/identities collide.
221
+ const c = await provider.hmac({ data: "y", domain: "haechi:conformance:v1" });
222
+ assert(a !== c, "hmac ignores the data argument (same output for different data)");
223
+ });
224
+ await check("hmac separates domains", async () => {
225
+ const a = await provider.hmac({ data: "x", domain: "haechi:conformance:a" });
226
+ const b = await provider.hmac({ data: "x", domain: "haechi:conformance:b" });
227
+ assert(a !== b, "hmac does not separate domains (same output for different domains)");
228
+ });
229
+ await check("hmac requires a domain", async () => {
230
+ for (const badDomain of ["", undefined, null]) {
231
+ let rejected = false;
232
+ try {
233
+ await provider.hmac({ data: "x", domain: badDomain });
234
+ } catch {
235
+ rejected = true;
236
+ }
237
+ assert(rejected, `hmac accepted an invalid domain (${JSON.stringify(badDomain)})`);
238
+ }
239
+ });
240
+ }
241
+ }
242
+
243
+ if (failures.length > 0) {
244
+ throw new Error(`cryptoProvider conformance failed:\n- ${failures.join("\n- ")}`);
245
+ }
246
+ return { ok: true };
247
+ }
248
+
142
249
  export function canonicalize(value) {
143
250
  if (Array.isArray(value)) {
144
251
  return `[${value.map((item) => canonicalize(item)).join(",")}]`;
@@ -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");