haechi 0.8.0 → 1.0.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.
@@ -15,7 +15,8 @@
15
15
  "failureMode": "fail-closed",
16
16
  "allowNonJson": false,
17
17
  "allowCompressed": false,
18
- "maxBytes": 1048576
18
+ "maxBytes": 1048576,
19
+ "scanNumbers": false
19
20
  },
20
21
  "streaming": {
21
22
  "requestMode": "block",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haechi",
3
- "version": "0.8.0",
3
+ "version": "1.0.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",
@@ -72,8 +72,9 @@
72
72
  "sbom": "node scripts/generate-sbom.mjs",
73
73
  "checksums": "node scripts/release-checksums.mjs",
74
74
  "bench:payload": "node scripts/bench-payload.mjs",
75
- "release:preflight": "node scripts/release-preflight.mjs",
76
- "release:preflight:npm": "node scripts/release-preflight.mjs --require-npm-auth",
75
+ "check:peer-ranges": "node scripts/check-satellite-peer-ranges.mjs",
76
+ "release:preflight": "node scripts/release-preflight.mjs && node scripts/check-satellite-peer-ranges.mjs",
77
+ "release:preflight:npm": "node scripts/release-preflight.mjs --require-npm-auth && node scripts/check-satellite-peer-ranges.mjs",
77
78
  "haechi": "node packages/cli/bin/haechi.mjs",
78
79
  "demo:init": "node packages/cli/bin/haechi.mjs init --force",
79
80
  "demo:protect": "node packages/cli/bin/haechi.mjs protect examples/llm-prompt-filtering/input.json --config haechi.config.json",
@@ -5,7 +5,30 @@ import { dirname } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
6
  import { setTimeout as delay } from "node:timers/promises";
7
7
 
8
- const FORBIDDEN_KEYS = new Set(["value", "plaintext", "payload", "content", "message", "prompt", "secret"]);
8
+ const FORBIDDEN_KEYS = new Set([
9
+ "value", "plaintext", "payload", "content", "message", "prompt", "secret",
10
+ // OIDC-broker / OAuth token, secret, and authorization-flow parameter keys.
11
+ // These are never part of a current audit event shape; the membership is a
12
+ // defense-in-depth guard so a future audit field can never leak a token,
13
+ // client secret, or flow parameter through the core sink. `sub`/`email` are
14
+ // intentionally NOT listed — they can be legitimate non-secret field names
15
+ // elsewhere, and the broker already self-guards them via its own allowlist
16
+ // projection.
17
+ "access_token", "id_token", "refresh_token", "code", "code_verifier",
18
+ "client_secret", "state", "nonce",
19
+ // Plugin/claims surface (1.0): a dynamically-loaded auth plugin's lifecycle
20
+ // events carry only ids/hashes/counts, but this additive membership is
21
+ // defense-in-depth so a future plugin event can never leak a raw claim, the
22
+ // received credential/authorization, the signer's signature, or the entry
23
+ // source into the chained log.
24
+ "claims", "subject", "issuer", "credential", "authorization", "signature", "entry",
25
+ // The frozen 1.0 audit-identity contract is exactly {id,type,subjectHash,
26
+ // issuerHash,provider} — scopes/labels are NOT part of it. This additive
27
+ // guard ensures that even if a future code path passes an un-projected
28
+ // identity object, scopes/labels (which can carry attacker-controlled plugin
29
+ // claim values) can never enter the hash-chained audit record.
30
+ "scopes", "labels"
31
+ ]);
9
32
 
10
33
  export function createJsonlAuditSink({ path, anchor = null }) {
11
34
  if (!path) {
@@ -212,4 +212,177 @@ export function createBearerAuthProvider({ path, cryptoProvider }) {
212
212
  };
213
213
  }
214
214
 
215
+ // Conformance suite for any authProvider — the CORRECTNESS gate the plugin
216
+ // loader runs before wiring a (sandboxed) auth plugin. It is NOT a malice screen
217
+ // (a signed plugin can detect a fixed test and behave, so vectors are randomized
218
+ // per run and the host re-validates PII-safety per call); it asserts the
219
+ // enumerated security behaviors of the authProvider contract:
220
+ // - missing credential -> null
221
+ // - malformed credential -> null
222
+ // - expired / not-yet-valid credential (clock via injected now) -> null
223
+ // - an internal throw surfaces to the caller as null (never propagates)
224
+ // - a returned identity MUST carry subjectHash AND issuerHash, and MUST NOT
225
+ // contain any field whose value equals the raw input subject or issuer
226
+ // - deny is DETERMINISTIC for identical input
227
+ // - a valid credential -> a well-formed PII-safe identity
228
+ //
229
+ // vectors lets a caller supply the request builders / raw values; by default a
230
+ // randomized-per-run vector set is generated so a plugin cannot hardcode the
231
+ // test. Mirrors assertCryptoProviderConformance's check/assert/failures shape.
232
+ export async function assertAuthProviderConformance(provider, { now = Date.now(), vectors } = {}) {
233
+ const failures = [];
234
+ const check = async (name, fn) => {
235
+ try {
236
+ await fn();
237
+ } catch (error) {
238
+ failures.push(`${name}: ${error.message}`);
239
+ }
240
+ };
241
+ const assert = (condition, message) => {
242
+ if (!condition) {
243
+ throw new Error(message);
244
+ }
245
+ };
246
+
247
+ if (typeof provider?.authenticate !== "function") {
248
+ throw new Error("authProvider must implement authenticate()");
249
+ }
250
+
251
+ const v = vectors ?? randomAuthVectors(now);
252
+
253
+ // A contract-conformant authProvider must never throw into the caller. We wrap
254
+ // every call so a throw becomes an explicit failure in the relevant check
255
+ // rather than aborting the whole suite — except the dedicated throw-vector
256
+ // check below, which asserts the provider itself swallowed the throw.
257
+ const callRaw = (request) => provider.authenticate(request);
258
+ const callSafe = async (request) => {
259
+ try {
260
+ return await provider.authenticate(request);
261
+ } catch (error) {
262
+ return { __threw: true, error };
263
+ }
264
+ };
265
+
266
+ await check("missing credential -> null", async () => {
267
+ const result = await callSafe(v.missing.request);
268
+ assert(!result?.__threw, "authenticate threw on a missing credential (must return null)");
269
+ assert(result === null, "missing credential must deny with null");
270
+ });
271
+
272
+ await check("malformed credential -> null", async () => {
273
+ const result = await callSafe(v.malformed.request);
274
+ assert(!result?.__threw, "authenticate threw on a malformed credential (must return null)");
275
+ assert(result === null, "malformed credential must deny with null");
276
+ });
277
+
278
+ await check("expired credential -> null", async () => {
279
+ const result = await callSafe(v.expired.request);
280
+ assert(!result?.__threw, "authenticate threw on an expired credential (must return null)");
281
+ assert(result === null, "expired credential must deny with null");
282
+ });
283
+
284
+ await check("not-yet-valid credential -> null", async () => {
285
+ const result = await callSafe(v.notYetValid.request);
286
+ assert(!result?.__threw, "authenticate threw on a not-yet-valid credential (must return null)");
287
+ assert(result === null, "not-yet-valid credential must deny with null");
288
+ });
289
+
290
+ await check("an internal throw surfaces to the caller as null (never propagates)", async () => {
291
+ let propagated = false;
292
+ let result;
293
+ try {
294
+ result = await callRaw(v.throwing.request);
295
+ } catch {
296
+ propagated = true;
297
+ }
298
+ assert(!propagated, "authenticate propagated an internal throw (must catch and deny with null)");
299
+ assert(result === null, "an internal error must deny with null, not a non-null identity");
300
+ });
301
+
302
+ await check("deny is deterministic for identical input", async () => {
303
+ const a = await callSafe(v.malformed.request);
304
+ const b = await callSafe(v.malformed.request);
305
+ assert(!a?.__threw && !b?.__threw, "authenticate threw while checking determinism");
306
+ assert(a === b, "deny is not deterministic for identical input (expected null both times)");
307
+ assert(a === null, "expected a deterministic null deny");
308
+ });
309
+
310
+ await check("valid credential -> a well-formed, PII-safe identity", async () => {
311
+ const identity = await callSafe(v.valid.request);
312
+ assert(!identity?.__threw, "authenticate threw on a valid credential");
313
+ assert(identity && typeof identity === "object", "a valid credential must return an identity object");
314
+ assert(typeof identity.subjectHash === "string" && identity.subjectHash.length > 0,
315
+ "identity must carry a non-empty subjectHash");
316
+ assert(typeof identity.issuerHash === "string" && identity.issuerHash.length > 0,
317
+ "identity must carry a non-empty issuerHash");
318
+ // PII-safety: no field value may equal the raw input subject or issuer.
319
+ assertNoRawPii(identity, v.valid.subject, v.valid.issuer, assert);
320
+
321
+ // Determinism for the accept path too: identical valid input -> identical
322
+ // identity (a non-deterministic identity breaks audit correlation).
323
+ const again = await callSafe(v.valid.request);
324
+ assert(!again?.__threw, "authenticate threw on a repeated valid credential");
325
+ assert(JSON.stringify(again) === JSON.stringify(identity),
326
+ "accept is not deterministic for identical valid input");
327
+ });
328
+
329
+ if (failures.length > 0) {
330
+ return { ok: false, failures };
331
+ }
332
+ return { ok: true, failures: [] };
333
+ }
334
+
335
+ // Recursively assert no value in the identity equals the raw subject/issuer. The
336
+ // keyed-HMAC subjectHash/issuerHash are derived from these, so an equality
337
+ // against the raw value would mean a PII leak (or an un-hashed passthrough).
338
+ function assertNoRawPii(value, subject, issuer, assert, path = "identity") {
339
+ if (typeof value === "string") {
340
+ assert(value !== subject, `${path} contains the raw subject (PII leak)`);
341
+ assert(value !== issuer, `${path} contains the raw issuer (PII leak)`);
342
+ return;
343
+ }
344
+ if (Array.isArray(value)) {
345
+ value.forEach((item, i) => assertNoRawPii(item, subject, issuer, assert, `${path}[${i}]`));
346
+ return;
347
+ }
348
+ if (value && typeof value === "object") {
349
+ for (const [key, item] of Object.entries(value)) {
350
+ assertNoRawPii(item, subject, issuer, assert, `${path}.${key}`);
351
+ }
352
+ }
353
+ }
354
+
355
+ function randomAuthVectors(now) {
356
+ const nowMs = typeof now === "number" ? now : Date.parse(now);
357
+ // Per-run random so a plugin cannot hardcode the expected test values.
358
+ const nonce = randomBytes(8).toString("hex");
359
+ const subject = `subj-${randomBytes(6).toString("hex")}`;
360
+ const issuer = `iss-${randomBytes(6).toString("hex")}`;
361
+ const validToken = `valid.${nonce}.${randomBytes(8).toString("hex")}`;
362
+ const expiredToken = `expired.${nonce}.${randomBytes(8).toString("hex")}`;
363
+ const notYetToken = `notyet.${nonce}.${randomBytes(8).toString("hex")}`;
364
+ const malformedToken = `~malformed~${nonce}`;
365
+ const throwToken = `throw.${nonce}`;
366
+
367
+ const bearer = (token) => ({ headers: { authorization: `Bearer ${token}` } });
368
+ // The valid credential encodes the random subject/issuer so any provider that
369
+ // echoes/leaks them into the returned identity is caught by assertNoRawPii.
370
+ // The credential is structured as "valid.<nonce>.<randHex>.<subject>.<issuer>"
371
+ // so a provider COULD extract them — but it MUST then keyed-hash them (not echo
372
+ // them raw) for the PII-safety assertion to pass. This makes the default-vector
373
+ // PII check non-vacuous: a leaking provider fails without custom vectors.
374
+ const validTokenWithPii = `${validToken}.${subject}.${issuer}`;
375
+ return {
376
+ nowMs,
377
+ subject,
378
+ issuer,
379
+ missing: { request: { headers: {} } },
380
+ malformed: { request: bearer(malformedToken), token: malformedToken },
381
+ expired: { request: bearer(expiredToken), token: expiredToken },
382
+ notYetValid: { request: bearer(notYetToken), token: notYetToken },
383
+ throwing: { request: bearer(throwToken), token: throwToken },
384
+ valid: { request: bearer(validTokenWithPii), token: validTokenWithPii, subject, issuer }
385
+ };
386
+ }
387
+
215
388
  export { DEFAULT_ALLOWED_LABEL_KEYS };
@@ -10,8 +10,22 @@ 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
12
  import { createBearerAuthProvider } from "../auth/index.mjs";
13
+ import { createSandboxedAuthProviderSync } from "../plugin/index.mjs";
13
14
  import { DEFAULT_PROXY_PORT } from "../proxy/index.mjs";
14
15
 
16
+ // Capability keys an operator may allowlist for a plugin. Mirrors the plugin
17
+ // manifest's declared-capability set plus the authProvider-specific
18
+ // readsCredentials. Inlined (not imported) to keep the dependency one-way.
19
+ const KNOWN_PLUGIN_CAPABILITIES = new Set([
20
+ "readsPlaintext",
21
+ "writesPlaintext",
22
+ "networkEgress",
23
+ "fileWrite",
24
+ "auditWrite",
25
+ "externalSecrets",
26
+ "readsCredentials"
27
+ ]);
28
+
15
29
  export const DEFAULT_CONFIG_PATH = "haechi.config.json";
16
30
 
17
31
  export function defaultConfig() {
@@ -32,7 +46,8 @@ export function defaultConfig() {
32
46
  failureMode: "fail-closed",
33
47
  allowNonJson: false,
34
48
  allowCompressed: false,
35
- maxBytes: 1048576
49
+ maxBytes: 1048576,
50
+ scanNumbers: false
36
51
  },
37
52
  streaming: {
38
53
  requestMode: "block",
@@ -84,6 +99,12 @@ export function defaultConfig() {
84
99
  store: ".haechi/auth.json",
85
100
  allowedLabelKeys: ["team", "env", "tier", "role"]
86
101
  },
102
+ // Top-level kill-switch for dynamic plugin loading (1.0 §2.2). Default true;
103
+ // an operator sets `plugins.enabled: false` to force-refuse construction of
104
+ // any sandboxed plugin (a live force-drop, since revocation is next-load).
105
+ plugins: {
106
+ enabled: true
107
+ },
87
108
  mcp: {
88
109
  allowedMethods: ["initialize", "tools/call", "resources/read", "prompts/get"],
89
110
  protectParams: true,
@@ -127,8 +148,10 @@ export function createRuntime(config, providers = {}) {
127
148
  // closed at construction rather than deep in a request if a needing feature
128
149
  // is configured without it.
129
150
  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");
151
+ && (normalized.auth.provider === "bearer"
152
+ || normalized.auth.provider === "plugin"
153
+ || normalized.tokenVault.deterministic)) {
154
+ throw new Error("cryptoProvider must implement hmac() for bearer/plugin auth / deterministic tokenization");
132
155
  }
133
156
  const auditSink = providers.auditSink ?? createJsonlAuditSink({
134
157
  path: normalized.audit.path,
@@ -168,7 +191,7 @@ export function createRuntime(config, providers = {}) {
168
191
  const policyEngine = providers.policyEngine ?? policyProfiles.base.policyEngine;
169
192
  assertProvider("policyEngine", policyEngine, ["decide"]);
170
193
 
171
- const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider);
194
+ const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider, auditSink);
172
195
 
173
196
  return {
174
197
  config: normalized,
@@ -249,6 +272,10 @@ export function normalizeConfig(config) {
249
272
  ...(config.auth ?? {}),
250
273
  allowedLabelKeys: config.auth?.allowedLabelKeys ?? defaultConfig().auth.allowedLabelKeys
251
274
  },
275
+ plugins: {
276
+ ...defaultConfig().plugins,
277
+ ...(config.plugins ?? {})
278
+ },
252
279
  mcp: {
253
280
  ...defaultConfig().mcp,
254
281
  ...(config.mcp ?? {}),
@@ -320,6 +347,9 @@ export function normalizeConfig(config) {
320
347
  if (typeof merged.responseProtection.maxBytes !== "number" || merged.responseProtection.maxBytes < 1) {
321
348
  throw new Error("responseProtection.maxBytes must be a positive number");
322
349
  }
350
+ if (typeof merged.responseProtection.scanNumbers !== "boolean") {
351
+ throw new Error("responseProtection.scanNumbers must be boolean");
352
+ }
323
353
  if (!["block", "pass-through", "inspect"].includes(merged.streaming.requestMode)) {
324
354
  throw new Error(`Invalid streaming.requestMode: ${merged.streaming.requestMode}`);
325
355
  }
@@ -336,7 +366,7 @@ export function normalizeConfig(config) {
336
366
  throw new Error("limits.upstreamTimeoutMs must be a positive number");
337
367
  }
338
368
  validatePolicyExtras(merged.policy);
339
- if (!["none", "bearer", "external"].includes(merged.auth.provider)) {
369
+ if (!["none", "bearer", "external", "plugin"].includes(merged.auth.provider)) {
340
370
  throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
341
371
  }
342
372
  if (typeof merged.auth.store !== "string" || !merged.auth.store.trim()) {
@@ -346,6 +376,9 @@ export function normalizeConfig(config) {
346
376
  || !merged.auth.allowedLabelKeys.every((key) => typeof key === "string" && key.trim())) {
347
377
  throw new Error("auth.allowedLabelKeys must be an array of non-empty strings");
348
378
  }
379
+ if (merged.auth.provider === "plugin") {
380
+ validatePluginAuthConfig(merged);
381
+ }
349
382
  createProtocolAdapter(merged.target);
350
383
  return merged;
351
384
  }
@@ -407,7 +440,124 @@ function assertRate(value, label) {
407
440
  }
408
441
  }
409
442
 
410
- function resolveAuthProvider(config, providers, cryptoProvider) {
443
+ // Enumerated, fail-closed validation of auth.provider:"plugin" (1.0 §2.3). Every
444
+ // rule throws a distinct error so a bad option is attributable. Mirrors the
445
+ // keys/tokenVault rigor — no silent degradation.
446
+ function validatePluginAuthConfig(merged) {
447
+ // Kill-switch: refuse to construct any plugin when plugins.enabled is false.
448
+ if (typeof merged.plugins?.enabled !== "boolean") {
449
+ throw new Error("plugins.enabled must be boolean");
450
+ }
451
+ if (merged.plugins.enabled === false) {
452
+ throw new Error("plugins are disabled (plugins.enabled: false); refusing to construct a plugin authProvider");
453
+ }
454
+
455
+ const plugin = merged.auth.plugin;
456
+ if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) {
457
+ throw new Error("auth.provider 'plugin' requires an auth.plugin object");
458
+ }
459
+ if (typeof plugin.manifestPath !== "string" || !plugin.manifestPath.trim()) {
460
+ throw new Error("auth.plugin.manifestPath must be a non-empty string");
461
+ }
462
+
463
+ // trustAnchors: a non-empty array of {keyId, publicKey} OR a non-empty object
464
+ // map keyId -> publicKey/anchor.
465
+ const anchors = plugin.trustAnchors;
466
+ if (Array.isArray(anchors)) {
467
+ if (anchors.length === 0) {
468
+ throw new Error("auth.plugin.trustAnchors must be a non-empty array");
469
+ }
470
+ for (const anchor of anchors) {
471
+ if (!anchor || typeof anchor !== "object"
472
+ || typeof anchor.keyId !== "string" || !anchor.keyId.trim()
473
+ || anchor.publicKey === undefined || anchor.publicKey === null) {
474
+ throw new Error("each auth.plugin.trustAnchors entry must be { keyId, publicKey }");
475
+ }
476
+ }
477
+ } else if (anchors && typeof anchors === "object") {
478
+ const keys = Object.keys(anchors);
479
+ if (keys.length === 0) {
480
+ throw new Error("auth.plugin.trustAnchors must be a non-empty object");
481
+ }
482
+ for (const keyId of keys) {
483
+ if (anchors[keyId] === undefined || anchors[keyId] === null) {
484
+ throw new Error(`auth.plugin.trustAnchors.${keyId} must be a public key`);
485
+ }
486
+ }
487
+ } else {
488
+ throw new Error("auth.plugin.trustAnchors must be a non-empty array or object of { keyId, publicKey }");
489
+ }
490
+
491
+ // allowCapabilities: an array of known capability keys, including readsCredentials.
492
+ if (!Array.isArray(plugin.allowCapabilities) || plugin.allowCapabilities.length === 0) {
493
+ throw new Error("auth.plugin.allowCapabilities must be a non-empty array of capability keys");
494
+ }
495
+ for (const capability of plugin.allowCapabilities) {
496
+ if (typeof capability !== "string" || !KNOWN_PLUGIN_CAPABILITIES.has(capability)) {
497
+ throw new Error(`auth.plugin.allowCapabilities contains an unknown capability: ${capability}`);
498
+ }
499
+ }
500
+ if (!plugin.allowCapabilities.includes("readsCredentials")) {
501
+ throw new Error("auth.plugin.allowCapabilities must include readsCredentials for an authProvider");
502
+ }
503
+
504
+ if (!Number.isInteger(plugin.timeoutMs) || plugin.timeoutMs <= 0) {
505
+ throw new Error("auth.plugin.timeoutMs must be a positive integer");
506
+ }
507
+
508
+ const limits = plugin.resourceLimits;
509
+ if (!limits || typeof limits !== "object" || Array.isArray(limits)
510
+ || !Number.isInteger(limits.maxOldGenerationSizeMb) || limits.maxOldGenerationSizeMb <= 0) {
511
+ throw new Error("auth.plugin.resourceLimits.maxOldGenerationSizeMb must be a positive integer");
512
+ }
513
+
514
+ if (plugin.maxPendingCalls !== undefined
515
+ && (!Number.isInteger(plugin.maxPendingCalls) || plugin.maxPendingCalls < 1)) {
516
+ throw new Error("auth.plugin.maxPendingCalls must be a positive integer");
517
+ }
518
+ if (plugin.maxMessageBytes !== undefined
519
+ && (!Number.isInteger(plugin.maxMessageBytes) || plugin.maxMessageBytes < 1)) {
520
+ throw new Error("auth.plugin.maxMessageBytes must be a positive integer");
521
+ }
522
+
523
+ if (plugin.pin !== undefined && plugin.pin !== null) {
524
+ if (typeof plugin.pin !== "object" || Array.isArray(plugin.pin)) {
525
+ throw new Error("auth.plugin.pin must be an object");
526
+ }
527
+ for (const field of ["version", "entrySha256", "manifestSha256"]) {
528
+ if (plugin.pin[field] !== undefined && plugin.pin[field] !== null
529
+ && (typeof plugin.pin[field] !== "string" || !plugin.pin[field].trim())) {
530
+ throw new Error(`auth.plugin.pin.${field} must be a non-empty string`);
531
+ }
532
+ }
533
+ }
534
+
535
+ if (plugin.revoked !== undefined && plugin.revoked !== null) {
536
+ if (typeof plugin.revoked !== "object" || Array.isArray(plugin.revoked)) {
537
+ throw new Error("auth.plugin.revoked must be an object");
538
+ }
539
+ for (const field of ["signerKeyIds", "entrySha256"]) {
540
+ if (plugin.revoked[field] !== undefined
541
+ && (!Array.isArray(plugin.revoked[field])
542
+ || !plugin.revoked[field].every((v) => typeof v === "string" && v.trim()))) {
543
+ throw new Error(`auth.plugin.revoked.${field} must be an array of non-empty strings`);
544
+ }
545
+ }
546
+ }
547
+
548
+ if (plugin.versionFloor !== undefined && plugin.versionFloor !== null) {
549
+ if (typeof plugin.versionFloor !== "object" || Array.isArray(plugin.versionFloor)) {
550
+ throw new Error("auth.plugin.versionFloor must be an object mapping pluginId -> version");
551
+ }
552
+ for (const [id, floor] of Object.entries(plugin.versionFloor)) {
553
+ if (typeof floor !== "string" || !floor.trim()) {
554
+ throw new Error(`auth.plugin.versionFloor.${id} must be a non-empty version string`);
555
+ }
556
+ }
557
+ }
558
+ }
559
+
560
+ function resolveAuthProvider(config, providers, cryptoProvider, auditSink) {
411
561
  if (config.auth.provider === "external") {
412
562
  if (typeof providers.authProvider?.authenticate !== "function") {
413
563
  throw new Error("auth.provider external requires createRuntime(config, { authProvider })");
@@ -421,9 +571,42 @@ function resolveAuthProvider(config, providers, cryptoProvider) {
421
571
  if (config.auth.provider === "bearer") {
422
572
  return createBearerAuthProvider({ path: config.auth.store, cryptoProvider });
423
573
  }
574
+ if (config.auth.provider === "plugin") {
575
+ const plugin = config.auth.plugin;
576
+ return createSandboxedAuthProviderSync({
577
+ manifestPath: plugin.manifestPath,
578
+ trustAnchors: normalizeTrustAnchors(plugin.trustAnchors),
579
+ allowCapabilities: plugin.allowCapabilities,
580
+ pin: plugin.pin ?? null,
581
+ revoked: plugin.revoked ?? {},
582
+ versionFloor: plugin.versionFloor ?? {},
583
+ timeoutMs: plugin.timeoutMs,
584
+ maxPendingCalls: plugin.maxPendingCalls,
585
+ maxMessageBytes: plugin.maxMessageBytes,
586
+ resourceLimits: plugin.resourceLimits,
587
+ coreVersion: plugin.coreVersion ?? null,
588
+ cryptoProvider,
589
+ auditSink,
590
+ allowedLabelKeys: config.auth.allowedLabelKeys
591
+ });
592
+ }
424
593
  return null;
425
594
  }
426
595
 
596
+ // The config form is a non-empty array of { keyId, publicKey } OR an object map.
597
+ // verifySignedPlugin resolves the anchor by signerKeyId against an object map, so
598
+ // normalize an array form into that map here.
599
+ function normalizeTrustAnchors(anchors) {
600
+ if (Array.isArray(anchors)) {
601
+ const map = {};
602
+ for (const anchor of anchors) {
603
+ map[anchor.keyId] = anchor.publicKey;
604
+ }
605
+ return map;
606
+ }
607
+ return anchors;
608
+ }
609
+
427
610
  function createConfiguredCryptoProvider(config) {
428
611
  if (config.keys.provider === "external") {
429
612
  throw new Error("keys.provider external requires createRuntime(config, { cryptoProvider })");
@@ -15,6 +15,10 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
15
15
  const effectiveMode = context.mode ?? mode;
16
16
  const engine = contextEngine ?? policyEngine;
17
17
  const entries = collectStringEntries(payload);
18
+ // `context` is threaded into detection as-is and is LOAD-BEARING: e.g.
19
+ // `context.direction` ("request" | "response") gates direction-scoped rules
20
+ // (injection) and the response-only marker exclusion in the filter engine.
21
+ // The proxy sets it per direction; do not drop it here.
18
22
  const detections = await filterEngine.detect({ entries, context });
19
23
  const decisions = [];
20
24
 
@@ -383,14 +387,29 @@ async function replacementFor(segment, detection, decision, { context, cryptoPro
383
387
 
384
388
  function buildAuditEvent({ context, mode, enforced, blocked, payload, detections, decisions }) {
385
389
  return {
390
+ // Reader-facing audit-event schema version (frozen as part of the 1.0 API
391
+ // contract — see docs/current/api-stability.md). Additive-only: a new field
392
+ // bumps nothing here; only a canonicalization change is a MAJOR schema bump
393
+ // (a new value + a reader migration). It is part of the canonicalized object
394
+ // and so is self-consistent for hash-chain verification of new events.
395
+ schemaVersion: "1",
386
396
  id: randomUUID(),
387
397
  timestamp: new Date().toISOString(),
388
398
  protocol: context.protocol ?? "custom",
389
399
  operation: context.operation ?? "protect",
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,
400
+ // PII-safe identity projected to the five frozen 1.0 audit-identity keys
401
+ // (id, type, subjectHash, issuerHash, provider). scopes/labels are available
402
+ // to the live policy engine via context.identity but are NOT part of the
403
+ // frozen audit schema (§2.1) and must never be persisted to the hash-chained
404
+ // log (an untrusted plugin's attacker-controlled label/scope value would
405
+ // otherwise enter the immutable audit record via this path).
406
+ identity: context.identity ? {
407
+ id: context.identity.id,
408
+ type: context.identity.type,
409
+ subjectHash: context.identity.subjectHash,
410
+ issuerHash: context.identity.issuerHash,
411
+ provider: context.identity.provider
412
+ } : null,
394
413
  profile: context.profile ?? null,
395
414
  mode,
396
415
  enforced,
@@ -7,11 +7,14 @@ const DEFAULT_RULES = [
7
7
  confidence: 0.95
8
8
  },
9
9
  {
10
+ // KR mobile numbers (01[016789] prefixes); landlines are out of scope.
11
+ // krPhoneValid keeps a bare separator-less run from matching a timestamp/id.
10
12
  id: "kr-phone",
11
13
  type: "phone",
12
14
  pattern: "(?:\\+82[-\\s]?)?0?1[016789][-.\\s]?\\d{3,4}[-.\\s]?\\d{4}",
13
15
  flags: "g",
14
- confidence: 0.9
16
+ confidence: 0.9,
17
+ validate: krPhoneValid
15
18
  },
16
19
  {
17
20
  id: "kr-rrn-like",
@@ -116,6 +119,27 @@ export function createDefaultFilterEngine({ customRules = [] } = {}) {
116
119
 
117
120
  export function detectEntry(entry, rules, context = {}) {
118
121
  const detections = [];
122
+ // On the RESPONSE direction, a bare JSON NUMBER leaf is inference-server
123
+ // metadata (a nanosecond `*_duration`, a token count, a numeric id/timestamp) —
124
+ // never a model-leaked card/phone/RRN. Scanning it only yields false positives:
125
+ // a long Luhn-passing duration matches `card`, a 13-digit one matches `kr_rrn`.
126
+ // The REQUEST direction still scans numbers (a client CAN send a card as a
127
+ // number); model-leaked PII lands in generated TEXT (string leaves), which are
128
+ // still inspected. (Accepted residual: a hostile model could exfiltrate a value
129
+ // as a bare response number — response inspection is a secondary defense.) A
130
+ // strict deployment can opt back in with `responseProtection.scanNumbers: true`
131
+ // (threaded as context.scanNumbers), accepting the metadata false positives.
132
+ if (context?.direction === "response" && entry.kind === "number" && !context?.scanNumbers) {
133
+ return detections;
134
+ }
135
+ // On the RESPONSE direction only, skip Haechi's own transform markers so they
136
+ // aren't re-detected: a tokenized round-trip echoes `[TOKEN:tok_…]` back, which
137
+ // reads like a `token:<secret>` assignment — without this, Haechi blocks its
138
+ // own token. This is response-only on purpose: a REQUEST that contains a
139
+ // marker-shaped string is NOT Haechi output (Haechi hasn't transformed it yet),
140
+ // so it is scanned normally — otherwise an attacker could wrap a real secret in
141
+ // a fake `[TOKEN:…]` to evade request-side detection.
142
+ const markerSpans = context?.direction === "response" ? haechiMarkerSpans(entry.value) : [];
119
143
 
120
144
  for (const rule of rules) {
121
145
  // Direction-scoped rules (e.g. injection heuristics) only run on the
@@ -129,14 +153,19 @@ export function detectEntry(entry, rules, context = {}) {
129
153
  if (rule.validate && !rule.validate(value)) {
130
154
  continue;
131
155
  }
156
+ const start = match.index;
157
+ const end = match.index + value.length;
158
+ if (overlapsAny(start, end, markerSpans)) {
159
+ continue;
160
+ }
132
161
  detections.push({
133
162
  type: rule.type,
134
163
  ruleId: rule.id,
135
164
  path: entry.path,
136
165
  pathText: entry.pathText,
137
166
  kind: entry.kind ?? "value",
138
- start: match.index,
139
- end: match.index + value.length,
167
+ start,
168
+ end,
140
169
  confidence: rule.confidence,
141
170
  value
142
171
  });
@@ -146,6 +175,32 @@ export function detectEntry(entry, rules, context = {}) {
146
175
  return removeOverlaps(detections);
147
176
  }
148
177
 
178
+ // Spans of Haechi's own transform markers in a string, so detection can skip
179
+ // them: `[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`.
180
+ function haechiMarkerSpans(text) {
181
+ const spans = [];
182
+ for (const m of text.matchAll(/\[(?:TOKEN|HAECHI_ENC|REDACTED):[^\]]*\]/g)) {
183
+ spans.push([m.index, m.index + m[0].length]);
184
+ }
185
+ return spans;
186
+ }
187
+
188
+ function overlapsAny(start, end, spans) {
189
+ return spans.some(([s, e]) => start < e && end > s);
190
+ }
191
+
192
+ // A bare digit run with no separators and no +82 country code is only treated as
193
+ // a KR phone number when it starts with the trunk prefix 0 (e.g. 01012345678);
194
+ // otherwise an ambiguous 10-digit value (a unix timestamp, an id, a counter)
195
+ // merely looks phone-shaped. Separated/prefixed forms (010-1234-5678,
196
+ // +82 10 1234 5678) always pass.
197
+ function krPhoneValid(match) {
198
+ if (/[-.\s+]/.test(match)) {
199
+ return true;
200
+ }
201
+ return match.startsWith("0");
202
+ }
203
+
149
204
  function normalizeCustomRule(rule) {
150
205
  if (!rule.id || !rule.type || !rule.pattern) {
151
206
  throw new Error("Custom filter rule requires id, type, and pattern");