haechi 0.9.0 → 1.1.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.
@@ -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 };
@@ -660,7 +660,7 @@ function printHelp(topic) {
660
660
  "plugin-validate", "mcp-stdio", "mcp-wrap", "auth", "config"
661
661
  ];
662
662
  const lines = order.map((name) => ` ${name.padEnd(16)}${COMMAND_HELP[name].summary}`);
663
- console.log(`Haechi — self-hosted AI context enforcement (developer preview)
663
+ console.log(`Haechi — self-hosted AI context enforcement
664
664
 
665
665
  Usage:
666
666
  haechi <command> [options]
@@ -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, createProcessIsolatedAuthProviderSync } 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() {
@@ -85,6 +99,12 @@ export function defaultConfig() {
85
99
  store: ".haechi/auth.json",
86
100
  allowedLabelKeys: ["team", "env", "tier", "role"]
87
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
+ },
88
108
  mcp: {
89
109
  allowedMethods: ["initialize", "tools/call", "resources/read", "prompts/get"],
90
110
  protectParams: true,
@@ -128,8 +148,10 @@ export function createRuntime(config, providers = {}) {
128
148
  // closed at construction rather than deep in a request if a needing feature
129
149
  // is configured without it.
130
150
  if (typeof cryptoProvider.hmac !== "function"
131
- && (normalized.auth.provider === "bearer" || normalized.tokenVault.deterministic)) {
132
- 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");
133
155
  }
134
156
  const auditSink = providers.auditSink ?? createJsonlAuditSink({
135
157
  path: normalized.audit.path,
@@ -169,7 +191,7 @@ export function createRuntime(config, providers = {}) {
169
191
  const policyEngine = providers.policyEngine ?? policyProfiles.base.policyEngine;
170
192
  assertProvider("policyEngine", policyEngine, ["decide"]);
171
193
 
172
- const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider);
194
+ const authProvider = resolveAuthProvider(normalized, providers, cryptoProvider, auditSink);
173
195
 
174
196
  return {
175
197
  config: normalized,
@@ -250,6 +272,10 @@ export function normalizeConfig(config) {
250
272
  ...(config.auth ?? {}),
251
273
  allowedLabelKeys: config.auth?.allowedLabelKeys ?? defaultConfig().auth.allowedLabelKeys
252
274
  },
275
+ plugins: {
276
+ ...defaultConfig().plugins,
277
+ ...(config.plugins ?? {})
278
+ },
253
279
  mcp: {
254
280
  ...defaultConfig().mcp,
255
281
  ...(config.mcp ?? {}),
@@ -340,7 +366,7 @@ export function normalizeConfig(config) {
340
366
  throw new Error("limits.upstreamTimeoutMs must be a positive number");
341
367
  }
342
368
  validatePolicyExtras(merged.policy);
343
- if (!["none", "bearer", "external"].includes(merged.auth.provider)) {
369
+ if (!["none", "bearer", "external", "plugin"].includes(merged.auth.provider)) {
344
370
  throw new Error(`Invalid auth.provider: ${merged.auth.provider}`);
345
371
  }
346
372
  if (typeof merged.auth.store !== "string" || !merged.auth.store.trim()) {
@@ -350,6 +376,9 @@ export function normalizeConfig(config) {
350
376
  || !merged.auth.allowedLabelKeys.every((key) => typeof key === "string" && key.trim())) {
351
377
  throw new Error("auth.allowedLabelKeys must be an array of non-empty strings");
352
378
  }
379
+ if (merged.auth.provider === "plugin") {
380
+ validatePluginAuthConfig(merged);
381
+ }
353
382
  createProtocolAdapter(merged.target);
354
383
  return merged;
355
384
  }
@@ -411,7 +440,161 @@ function assertRate(value, label) {
411
440
  }
412
441
  }
413
442
 
414
- 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
+ // isolation: "worker" (default, 1.0 worker_threads — memory/crash isolation) or
505
+ // "process" (1.1 — a --permission child with real capability enforcement).
506
+ const isolation = plugin.isolation ?? "worker";
507
+ if (!["worker", "process"].includes(isolation)) {
508
+ throw new Error(`auth.plugin.isolation must be "worker" or "process" (got: ${JSON.stringify(plugin.isolation)})`);
509
+ }
510
+
511
+ if (!Number.isInteger(plugin.timeoutMs) || plugin.timeoutMs <= 0) {
512
+ throw new Error("auth.plugin.timeoutMs must be a positive integer");
513
+ }
514
+
515
+ if (isolation === "worker") {
516
+ // worker_threads resourceLimits (heap bound). Required for the worker runtime.
517
+ const limits = plugin.resourceLimits;
518
+ if (!limits || typeof limits !== "object" || Array.isArray(limits)
519
+ || !Number.isInteger(limits.maxOldGenerationSizeMb) || limits.maxOldGenerationSizeMb <= 0) {
520
+ throw new Error("auth.plugin.resourceLimits.maxOldGenerationSizeMb must be a positive integer");
521
+ }
522
+ } else {
523
+ // process-isolated: resourceLimits is N/A (the child is OS-bounded). Validate
524
+ // the network-enforcement policy and the optional host-mediated key material.
525
+ const netEnforcement = plugin.netEnforcement ?? "require-permission";
526
+ if (netEnforcement !== "require-permission") {
527
+ throw new Error(`auth.plugin.netEnforcement must be "require-permission" (got: ${JSON.stringify(plugin.netEnforcement)})`);
528
+ }
529
+ if (plugin.keyMaterial !== undefined && plugin.keyMaterial !== null) {
530
+ const km = plugin.keyMaterial;
531
+ if (typeof km !== "object" || Array.isArray(km) || typeof km.url !== "string" || !km.url.trim()) {
532
+ throw new Error("auth.plugin.keyMaterial must be an object with an operator-declared url string");
533
+ }
534
+ let keyUrl;
535
+ try {
536
+ keyUrl = new URL(km.url);
537
+ } catch {
538
+ throw new Error("auth.plugin.keyMaterial.url must be a valid URL");
539
+ }
540
+ if (keyUrl.protocol !== "https:") {
541
+ throw new Error("auth.plugin.keyMaterial.url must be https");
542
+ }
543
+ for (const field of ["ttlMs", "cooldownMs", "timeoutMs", "maxBytes"]) {
544
+ if (km[field] !== undefined && (typeof km[field] !== "number" || km[field] < 0)) {
545
+ throw new Error(`auth.plugin.keyMaterial.${field} must be a non-negative number`);
546
+ }
547
+ }
548
+ }
549
+ }
550
+
551
+ if (plugin.maxPendingCalls !== undefined
552
+ && (!Number.isInteger(plugin.maxPendingCalls) || plugin.maxPendingCalls < 1)) {
553
+ throw new Error("auth.plugin.maxPendingCalls must be a positive integer");
554
+ }
555
+ if (plugin.maxMessageBytes !== undefined
556
+ && (!Number.isInteger(plugin.maxMessageBytes) || plugin.maxMessageBytes < 1)) {
557
+ throw new Error("auth.plugin.maxMessageBytes must be a positive integer");
558
+ }
559
+
560
+ if (plugin.pin !== undefined && plugin.pin !== null) {
561
+ if (typeof plugin.pin !== "object" || Array.isArray(plugin.pin)) {
562
+ throw new Error("auth.plugin.pin must be an object");
563
+ }
564
+ for (const field of ["version", "entrySha256", "manifestSha256"]) {
565
+ if (plugin.pin[field] !== undefined && plugin.pin[field] !== null
566
+ && (typeof plugin.pin[field] !== "string" || !plugin.pin[field].trim())) {
567
+ throw new Error(`auth.plugin.pin.${field} must be a non-empty string`);
568
+ }
569
+ }
570
+ }
571
+
572
+ if (plugin.revoked !== undefined && plugin.revoked !== null) {
573
+ if (typeof plugin.revoked !== "object" || Array.isArray(plugin.revoked)) {
574
+ throw new Error("auth.plugin.revoked must be an object");
575
+ }
576
+ for (const field of ["signerKeyIds", "entrySha256"]) {
577
+ if (plugin.revoked[field] !== undefined
578
+ && (!Array.isArray(plugin.revoked[field])
579
+ || !plugin.revoked[field].every((v) => typeof v === "string" && v.trim()))) {
580
+ throw new Error(`auth.plugin.revoked.${field} must be an array of non-empty strings`);
581
+ }
582
+ }
583
+ }
584
+
585
+ if (plugin.versionFloor !== undefined && plugin.versionFloor !== null) {
586
+ if (typeof plugin.versionFloor !== "object" || Array.isArray(plugin.versionFloor)) {
587
+ throw new Error("auth.plugin.versionFloor must be an object mapping pluginId -> version");
588
+ }
589
+ for (const [id, floor] of Object.entries(plugin.versionFloor)) {
590
+ if (typeof floor !== "string" || !floor.trim()) {
591
+ throw new Error(`auth.plugin.versionFloor.${id} must be a non-empty version string`);
592
+ }
593
+ }
594
+ }
595
+ }
596
+
597
+ function resolveAuthProvider(config, providers, cryptoProvider, auditSink) {
415
598
  if (config.auth.provider === "external") {
416
599
  if (typeof providers.authProvider?.authenticate !== "function") {
417
600
  throw new Error("auth.provider external requires createRuntime(config, { authProvider })");
@@ -425,9 +608,51 @@ function resolveAuthProvider(config, providers, cryptoProvider) {
425
608
  if (config.auth.provider === "bearer") {
426
609
  return createBearerAuthProvider({ path: config.auth.store, cryptoProvider });
427
610
  }
611
+ if (config.auth.provider === "plugin") {
612
+ const plugin = config.auth.plugin;
613
+ const common = {
614
+ manifestPath: plugin.manifestPath,
615
+ trustAnchors: normalizeTrustAnchors(plugin.trustAnchors),
616
+ allowCapabilities: plugin.allowCapabilities,
617
+ pin: plugin.pin ?? null,
618
+ revoked: plugin.revoked ?? {},
619
+ versionFloor: plugin.versionFloor ?? {},
620
+ timeoutMs: plugin.timeoutMs,
621
+ maxPendingCalls: plugin.maxPendingCalls,
622
+ maxMessageBytes: plugin.maxMessageBytes,
623
+ coreVersion: plugin.coreVersion ?? null,
624
+ cryptoProvider,
625
+ auditSink,
626
+ allowedLabelKeys: config.auth.allowedLabelKeys
627
+ };
628
+ if ((plugin.isolation ?? "worker") === "process") {
629
+ // 1.1 capability enforcement. Construction fails closed on a Node that
630
+ // cannot enforce --allow-net (netEnforcement: require-permission).
631
+ return createProcessIsolatedAuthProviderSync({
632
+ ...common,
633
+ netEnforcement: plugin.netEnforcement ?? "require-permission",
634
+ keyMaterial: plugin.keyMaterial ?? null
635
+ });
636
+ }
637
+ return createSandboxedAuthProviderSync({ ...common, resourceLimits: plugin.resourceLimits });
638
+ }
428
639
  return null;
429
640
  }
430
641
 
642
+ // The config form is a non-empty array of { keyId, publicKey } OR an object map.
643
+ // verifySignedPlugin resolves the anchor by signerKeyId against an object map, so
644
+ // normalize an array form into that map here.
645
+ function normalizeTrustAnchors(anchors) {
646
+ if (Array.isArray(anchors)) {
647
+ const map = {};
648
+ for (const anchor of anchors) {
649
+ map[anchor.keyId] = anchor.publicKey;
650
+ }
651
+ return map;
652
+ }
653
+ return anchors;
654
+ }
655
+
431
656
  function createConfiguredCryptoProvider(config) {
432
657
  if (config.keys.provider === "external") {
433
658
  throw new Error("keys.provider external requires createRuntime(config, { cryptoProvider })");
@@ -387,14 +387,29 @@ async function replacementFor(segment, detection, decision, { context, cryptoPro
387
387
 
388
388
  function buildAuditEvent({ context, mode, enforced, blocked, payload, detections, decisions }) {
389
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",
390
396
  id: randomUUID(),
391
397
  timestamp: new Date().toISOString(),
392
398
  protocol: context.protocol ?? "custom",
393
399
  operation: context.operation ?? "protect",
394
- // PII-safe identity built by the auth layer (subject/issuer are keyed
395
- // HMACs); null when no auth is configured. `profile` is the resolved
396
- // policy profile name (or null).
397
- 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,
398
413
  profile: context.profile ?? null,
399
414
  mode,
400
415
  enforced,
@@ -1,5 +1,22 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ export {
4
+ signPluginManifest,
5
+ verifySignedPlugin,
6
+ PluginLoadError,
7
+ PLUGIN_LOAD_REASONS
8
+ } from "./signing.mjs";
9
+
10
+ export {
11
+ createSandboxedAuthProvider,
12
+ createSandboxedAuthProviderSync
13
+ } from "./sandbox.mjs";
14
+
15
+ export {
16
+ createProcessIsolatedAuthProvider,
17
+ createProcessIsolatedAuthProviderSync
18
+ } from "./process-sandbox.mjs";
19
+
3
20
  const VALID_KINDS = new Set([
4
21
  "crypto-provider",
5
22
  "key-provider",
@@ -8,7 +25,10 @@ const VALID_KINDS = new Set([
8
25
  "token-vault",
9
26
  "audit-sink",
10
27
  "protocol-adapter",
11
- "classifier-plugin"
28
+ "classifier-plugin",
29
+ // 1.0: the first dynamically-loadable kind, only under the worker-isolated
30
+ // signed/capability-gated/audited sandbox.
31
+ "authProvider"
12
32
  ]);
13
33
 
14
34
  const CAPABILITY_KEYS = [
@@ -19,7 +39,13 @@ const CAPABILITY_KEYS = [
19
39
  "auditWrite",
20
40
  "externalSecrets"
21
41
  ];
22
- const VALID_RUNTIMES = new Set(["manifest-only"]);
42
+ // manifest-only is the historical, behavior-preserving path. worker-isolated
43
+ // (1.0) and process-isolated (1.1) are the dynamic-loading runtimes — both
44
+ // permitted ONLY for kind authProvider and only with the Ed25519 signed envelope
45
+ // (see validateSignedDynamicManifest). They share the same manifest contract; the
46
+ // difference is the isolation mechanism (worker_threads vs a --permission child).
47
+ const VALID_RUNTIMES = new Set(["manifest-only", "worker-isolated", "process-isolated"]);
48
+ const SIGNED_DYNAMIC_RUNTIMES = new Set(["worker-isolated", "process-isolated"]);
23
49
 
24
50
  export async function validatePluginManifestFile(path) {
25
51
  const manifest = JSON.parse(await readFile(path, "utf8"));
@@ -47,26 +73,36 @@ export function validatePluginManifest(manifest) {
47
73
  errors.push("dynamic plugin execution is not supported; set runtime to manifest-only");
48
74
  }
49
75
 
50
- if (!plugin.compatibility?.haechiCore) {
51
- errors.push("missing compatibility.haechiCore");
52
- }
53
-
54
- if (!plugin.capabilities || typeof plugin.capabilities !== "object") {
55
- errors.push("missing capabilities");
76
+ if (SIGNED_DYNAMIC_RUNTIMES.has(plugin.runtime)) {
77
+ // The dynamic-loading path (worker-isolated 1.0 / process-isolated 1.1): a
78
+ // separate, stricter contract (signed Ed25519 envelope + a validity window +
79
+ // authProvider-only). Kept apart from the manifest-only checks so the
80
+ // historical path is untouched.
81
+ validateSignedDynamicManifest(plugin, errors);
56
82
  } else {
57
- for (const key of CAPABILITY_KEYS) {
58
- if (typeof plugin.capabilities[key] !== "boolean") {
59
- errors.push(`capabilities.${key} must be boolean`);
83
+ // manifest-only (and any other declared-but-rejected runtime): the
84
+ // historical, behavior-preserving contract UNCHANGED.
85
+ if (!plugin.compatibility?.haechiCore) {
86
+ errors.push("missing compatibility.haechiCore");
87
+ }
88
+
89
+ if (!plugin.capabilities || typeof plugin.capabilities !== "object") {
90
+ errors.push("missing capabilities");
91
+ } else {
92
+ for (const key of CAPABILITY_KEYS) {
93
+ if (typeof plugin.capabilities[key] !== "boolean") {
94
+ errors.push(`capabilities.${key} must be boolean`);
95
+ }
60
96
  }
61
97
  }
62
- }
63
98
 
64
- if (plugin.capabilities?.networkEgress && plugin.capabilities.readsPlaintext && !plugin.dataHandling?.retention) {
65
- errors.push("plaintext-reading network plugins must declare dataHandling.retention");
66
- }
99
+ if (plugin.capabilities?.networkEgress && plugin.capabilities.readsPlaintext && !plugin.dataHandling?.retention) {
100
+ errors.push("plaintext-reading network plugins must declare dataHandling.retention");
101
+ }
67
102
 
68
- if (plugin.dataHandling?.logsRawPayload === true) {
69
- errors.push("dataHandling.logsRawPayload must not be true");
103
+ if (plugin.dataHandling?.logsRawPayload === true) {
104
+ errors.push("dataHandling.logsRawPayload must not be true");
105
+ }
70
106
  }
71
107
  }
72
108
 
@@ -76,6 +112,46 @@ export function validatePluginManifest(manifest) {
76
112
  };
77
113
  }
78
114
 
115
+ // The signed-dynamic runtimes (worker-isolated 1.0 / process-isolated 1.1) are
116
+ // dynamic code-loading; both are permitted ONLY for kind authProvider and ONLY
117
+ // with the Ed25519 signed envelope fields. A manifest that is not an authProvider,
118
+ // or is missing the signed fields / validity window / readsCredentials, is
119
+ // rejected with a clear error. The two runtimes share this identical contract.
120
+ function validateSignedDynamicManifest(plugin, errors) {
121
+ if (plugin.kind !== "authProvider") {
122
+ errors.push(`${plugin.runtime} runtime is only supported for kind authProvider`);
123
+ }
124
+
125
+ // The signed-envelope fields that bind authorship and the exact entry bytes.
126
+ // signature must be a non-empty base64-ish string; entrySha256 must be a
127
+ // 64-char lowercase hex string. Loose shapes signal a malformed/forged manifest.
128
+ if (!plugin.signature || typeof plugin.signature !== "string" || plugin.signature.length === 0) {
129
+ errors.push("missing signature");
130
+ } else if (!/^[A-Za-z0-9+/=]+$/.test(plugin.signature)) {
131
+ errors.push("signature must be a non-empty base64 string");
132
+ }
133
+ requireString(plugin, "signerKeyId", errors);
134
+ if (!plugin.entrySha256 || typeof plugin.entrySha256 !== "string" || plugin.entrySha256.length === 0) {
135
+ errors.push("missing entrySha256");
136
+ } else if (!/^[0-9a-f]{64}$/.test(plugin.entrySha256)) {
137
+ errors.push("entrySha256 must be a 64-character lowercase hex string");
138
+ }
139
+
140
+ // A validity window is mandatory for a dynamically-loaded artifact.
141
+ const hasNotBefore = plugin.notBefore !== undefined && plugin.notBefore !== null;
142
+ const hasNotAfter = plugin.notAfter !== undefined && plugin.notAfter !== null;
143
+ if (!hasNotBefore && !hasNotAfter) {
144
+ errors.push(`${plugin.runtime} manifest requires a validity window (notBefore and/or notAfter)`);
145
+ }
146
+
147
+ if (!plugin.capabilities || typeof plugin.capabilities !== "object" || Array.isArray(plugin.capabilities)) {
148
+ errors.push("missing capabilities");
149
+ } else if (plugin.capabilities.readsCredentials !== true) {
150
+ // An authProvider sees the bearer token, so it MUST declare readsCredentials.
151
+ errors.push(`${plugin.runtime} authProvider must declare capabilities.readsCredentials = true`);
152
+ }
153
+ }
154
+
79
155
  function requireString(object, key, errors) {
80
156
  if (!object[key] || typeof object[key] !== "string") {
81
157
  errors.push(`missing ${key}`);