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.
@@ -0,0 +1,243 @@
1
+ // Shared, security-critical primitives for the signed-authProvider sandboxes
2
+ // (Haechi 1.0 worker-isolated + 1.1 process-isolated).
3
+ //
4
+ // These helpers are the trust boundary that MUST behave identically for both
5
+ // runtimes — the null-prototype claims sanitizer, the bearer-only credential
6
+ // extraction, the signed-envelope reconstruction, and the full PR2 load gate.
7
+ // They live here so the worker and process sandboxes import ONE copy: a divergence
8
+ // between two private copies of the sanitizer would be a real prototype-pollution
9
+ // vulnerability. The transport-specific lifecycle (Worker vs child_process) stays
10
+ // in each sandbox module.
11
+ //
12
+ // Zero runtime dependency: node:crypto + node:fs + node:path only, plus the
13
+ // in-repo PR2 verifier (haechi/plugin/signing) and the manifest validator.
14
+
15
+ import { lstatSync, readFileSync, statSync } from "node:fs";
16
+ import { createHash } from "node:crypto";
17
+ import { dirname, resolve as resolvePath, sep as pathSep } from "node:path";
18
+ import { verifySignedPlugin } from "./signing.mjs";
19
+ import { validatePluginManifest } from "./index.mjs";
20
+
21
+ // The only own-enumerable keys the host accepts back from a sandboxed plugin.
22
+ // Anything else (incl. __proto__/constructor/prototype) is dropped at the boundary.
23
+ export const CLAIM_ALLOWLIST = ["subject", "issuer", "type", "scopes", "labels"];
24
+ // Defensive bounds so a hostile claims object cannot blow up the host build.
25
+ export const MAX_SCOPES = 64;
26
+ export const MAX_LABELS = 32;
27
+ export const MAX_STRING_LEN = 1024;
28
+ // A self-contained single-file plugin is loaded from a data: URL; refuse to read
29
+ // an unreasonably large entry into memory (a few MiB is generous for any auth
30
+ // plugin). Shared so both runtimes apply the identical bound.
31
+ export const MAX_ENTRY_BYTES = 4 * 1024 * 1024; // 4 MiB
32
+
33
+ export function sha256Hex(bytes) {
34
+ return createHash("sha256").update(bytes).digest("hex");
35
+ }
36
+
37
+ // Same parsing the bearer provider uses — ONLY the Authorization header, never
38
+ // the request body. Returns the bearer token slice (the credential) or null.
39
+ export function bearerCredentialFromRequest(request) {
40
+ const header = request?.headers?.authorization ?? request?.headers?.Authorization;
41
+ if (typeof header !== "string") {
42
+ return null;
43
+ }
44
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
45
+ return match ? match[1].trim() : null;
46
+ }
47
+
48
+ // Reconstruct the PR2 signed envelope ({ payload, signerKeyId, alg, signature })
49
+ // from a manifest's stored fields. Authors produce this with signPluginManifest;
50
+ // the manifest persists it under haechiPlugin.signed and mirrors the flat fields
51
+ // so validatePluginManifest can check the shape.
52
+ export function envelopeFromManifest(plugin) {
53
+ if (plugin.signed && typeof plugin.signed === "object") {
54
+ return plugin.signed;
55
+ }
56
+ return {
57
+ payload: plugin.signedPayload,
58
+ signerKeyId: plugin.signerKeyId,
59
+ alg: plugin.alg ?? "ed25519",
60
+ signature: plugin.signature
61
+ };
62
+ }
63
+
64
+ // Host-side claims sanitizer. The reply is parsed, then ONLY the allowlisted
65
+ // own-enumerable keys are copied onto a null-prototype object — __proto__/
66
+ // constructor/prototype can never reach buildExternalIdentity, and array/string
67
+ // sizes are bounded. Returns a plain {subject,issuer,type,scopes,labels} or
68
+ // throws (→ deny) on a structurally invalid claim.
69
+ export function sanitizeClaims(rawClaims) {
70
+ if (!rawClaims || typeof rawClaims !== "object" || Array.isArray(rawClaims)) {
71
+ throw new Error("claims must be an object");
72
+ }
73
+ const out = Object.create(null);
74
+ for (const key of CLAIM_ALLOWLIST) {
75
+ // Own-enumerable only; never walk the prototype.
76
+ if (!Object.prototype.hasOwnProperty.call(rawClaims, key)) {
77
+ continue;
78
+ }
79
+ out[key] = rawClaims[key];
80
+ }
81
+ // type-validate / bound each value at the boundary.
82
+ if (typeof out.subject !== "string" || out.subject.length === 0 || out.subject.length > MAX_STRING_LEN) {
83
+ throw new Error("claims.subject must be a bounded non-empty string");
84
+ }
85
+ if (typeof out.issuer !== "string" || out.issuer.length === 0 || out.issuer.length > MAX_STRING_LEN) {
86
+ throw new Error("claims.issuer must be a bounded non-empty string");
87
+ }
88
+ if (out.type !== undefined && typeof out.type !== "string") {
89
+ throw new Error("claims.type must be a string");
90
+ }
91
+ if (out.scopes !== undefined) {
92
+ if (!Array.isArray(out.scopes) || out.scopes.length > MAX_SCOPES
93
+ || !out.scopes.every((s) => typeof s === "string" && s.length > 0 && s.length <= MAX_STRING_LEN)) {
94
+ throw new Error("claims.scopes must be a bounded array of non-empty strings");
95
+ }
96
+ }
97
+ if (out.labels !== undefined) {
98
+ if (!out.labels || typeof out.labels !== "object" || Array.isArray(out.labels)) {
99
+ throw new Error("claims.labels must be an object");
100
+ }
101
+ const labelKeys = Object.keys(out.labels);
102
+ if (labelKeys.length > MAX_LABELS) {
103
+ throw new Error("claims.labels exceeds the size bound");
104
+ }
105
+ const bounded = Object.create(null);
106
+ for (const k of labelKeys) {
107
+ const v = out.labels[k];
108
+ if (typeof v !== "string" || v.length === 0 || v.length > MAX_STRING_LEN) {
109
+ throw new Error(`claims.labels.${k} must be a bounded non-empty string`);
110
+ }
111
+ bounded[k] = v;
112
+ }
113
+ out.labels = bounded;
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // A fire-and-forget audit wrapper. Lifecycle audit must NEVER make the auth path
119
+ // throw, so a sink that throws or rejects is swallowed.
120
+ export function makeFireAndForgetAudit(auditSink) {
121
+ return (event) => {
122
+ try {
123
+ const out = auditSink.record(event);
124
+ if (out && typeof out.then === "function") {
125
+ out.catch(() => {});
126
+ }
127
+ } catch {
128
+ // swallow — auditing is best-effort and never blocks fail-closed behavior
129
+ }
130
+ };
131
+ }
132
+
133
+ // Read+validate the manifest, resolve the entry path, read the entry bytes into
134
+ // memory, and run the FULL PR2 gate. Returns { verified, entrySource,
135
+ // entrySha256, pluginId, signerKeyId }. Throws (after emitting plugin.load.refused
136
+ // via `audit`) on any refusal. Both sandboxes call this; `expectedRuntime` is the
137
+ // manifest runtime string the caller requires ("worker-isolated" | "process-isolated").
138
+ // Re-run on every (re)spawn — the gate is not a one-time check.
139
+ export function loadAndVerifyPlugin({
140
+ manifestPath,
141
+ expectedRuntime,
142
+ trustAnchors,
143
+ allowCapabilities = [],
144
+ pin = null,
145
+ revoked = {},
146
+ versionFloor = {},
147
+ coreVersion = null,
148
+ now,
149
+ audit
150
+ }) {
151
+ // A tagged-throw helper so a refusal emits the audit at one site.
152
+ function refuse(reason, message, pluginId) {
153
+ const err = new Error(message);
154
+ err.reason = reason;
155
+ audit({ type: "plugin.load.refused", decision: "plugin.load.refused", reason, pluginId });
156
+ return { __haechiRefusal: true, cause: err };
157
+ }
158
+
159
+ let plugin;
160
+ let entrySource;
161
+ let entrySha256;
162
+ let pluginIdForAudit;
163
+ let signerKeyIdForAudit;
164
+ try {
165
+ const manifestRaw = JSON.parse(readFileSync(manifestPath, "utf8"));
166
+ plugin = manifestRaw?.haechiPlugin;
167
+ pluginIdForAudit = plugin?.id;
168
+ const validation = validatePluginManifest(manifestRaw);
169
+ if (!validation.valid) {
170
+ throw refuse("manifest-invalid", `manifest invalid: ${validation.errors.join("; ")}`);
171
+ }
172
+ if (plugin.runtime !== expectedRuntime) {
173
+ throw refuse("manifest-invalid", `sandbox requires runtime ${expectedRuntime}`);
174
+ }
175
+ if (plugin.kind !== "authProvider") {
176
+ throw refuse("manifest-invalid", "sandbox requires kind authProvider");
177
+ }
178
+
179
+ // Resolve the entry against the manifest dir. Reject a symlinked entry
180
+ // (anti-TOCTOU / swap): we hash and spawn from the in-memory bytes only.
181
+ const manifestDir = resolvePath(dirname(resolvePath(manifestPath)));
182
+ const entryPath = resolvePath(manifestDir, plugin.entrypoint);
183
+
184
+ // Entrypoint confinement: the resolved entry path MUST be inside the manifest
185
+ // directory. An absolute path or a `../`-escaping value resolves outside
186
+ // manifestDir and is an arbitrary-file-read primitive. Checked BEFORE any I/O.
187
+ if (!entryPath.startsWith(manifestDir + pathSep) && entryPath !== manifestDir) {
188
+ throw refuse("manifest-invalid", `entry path escapes the manifest directory: ${plugin.entrypoint}`);
189
+ }
190
+
191
+ const st = lstatSync(entryPath);
192
+ if (st.isSymbolicLink()) {
193
+ throw refuse("tampered-entry", "entry path is a symlink (refused)");
194
+ }
195
+
196
+ const entrySize = statSync(entryPath).size;
197
+ if (entrySize > MAX_ENTRY_BYTES) {
198
+ throw refuse("manifest-invalid", `entry file exceeds maximum size (${entrySize} > ${MAX_ENTRY_BYTES} bytes)`);
199
+ }
200
+
201
+ const entryBytes = readFileSync(entryPath); // INTO MEMORY — read exactly once.
202
+ entrySource = entryBytes.toString("utf8");
203
+ entrySha256 = sha256Hex(entryBytes);
204
+
205
+ signerKeyIdForAudit = envelopeFromManifest(plugin)?.signerKeyId;
206
+ } catch (error) {
207
+ if (error?.__haechiRefusal) {
208
+ throw error.cause;
209
+ }
210
+ const refusal = refuse("manifest-invalid", `manifest load failed: ${error.message}`, pluginIdForAudit);
211
+ throw refusal.cause;
212
+ }
213
+
214
+ // The PR2 gate (signature + anchor + revocation + tamper + window + floor +
215
+ // pin + capability allowlist + coreVersionRange). Any failure throws a
216
+ // PluginLoadError whose .reason is the audit reason.
217
+ let verified;
218
+ try {
219
+ verified = verifySignedPlugin({
220
+ signed: envelopeFromManifest(plugin),
221
+ entryBytes: Buffer.from(entrySource, "utf8"),
222
+ trustAnchors,
223
+ revoked,
224
+ pin,
225
+ versionFloor,
226
+ allowCapabilities,
227
+ coreVersion,
228
+ now
229
+ });
230
+ } catch (error) {
231
+ const reason = typeof error?.reason === "string" ? error.reason : "manifest-invalid";
232
+ audit({ type: "plugin.load.refused", decision: "plugin.load.refused", reason, pluginId: pluginIdForAudit, signerKeyId: signerKeyIdForAudit });
233
+ throw error;
234
+ }
235
+
236
+ return {
237
+ verified,
238
+ entrySource,
239
+ entrySha256,
240
+ pluginId: verified.pluginId,
241
+ signerKeyId: envelopeFromManifest(plugin).signerKeyId
242
+ };
243
+ }
@@ -0,0 +1,415 @@
1
+ // The worker-isolated authProvider sandbox (Haechi 1.0 §2.3/§2.4/§7.4).
2
+ //
3
+ // HONEST MODEL (read packages/../docs/current/release-1.0-implementation-scope.md
4
+ // §1): node:worker_threads is NOT a capability sandbox. A worker shares the
5
+ // process and a malicious *signed* plugin can still use fs/net/process.env — that
6
+ // residual is accepted and gated ONLY by the PR2 signing/trust gate, never by the
7
+ // worker. What the worker DOES give us, and all this module claims:
8
+ // - V8-heap memory isolation (the plugin cannot read the host's crypto key,
9
+ // token vault, or audit sink — only a typed JSON-string message crosses);
10
+ // - crash/hang containment via resourceLimits + a per-call timeout that
11
+ // terminates the worker (a hang fails closed → deny);
12
+ // - data minimization (the worker receives ONLY the credential slice, never the
13
+ // request body / key / sink; the HOST builds the keyed-HMAC identity);
14
+ // - a narrow, audited, correlation-id'd contract.
15
+ //
16
+ // Zero runtime dependency: node:worker_threads + node:crypto + node:fs only, plus
17
+ // in-repo haechi/plugin (PR2 verify) and haechi/auth (identity + conformance).
18
+
19
+ import { Worker } from "node:worker_threads";
20
+ import { randomUUID } from "node:crypto";
21
+ import { assertAuthProviderConformance, buildExternalIdentity } from "../auth/index.mjs";
22
+ import {
23
+ bearerCredentialFromRequest,
24
+ loadAndVerifyPlugin,
25
+ makeFireAndForgetAudit,
26
+ sanitizeClaims
27
+ } from "./sandbox-common.mjs";
28
+
29
+ // The wire harness the host wraps around the worker so a generic codeString plugin
30
+ // only has to .on("message")/.postMessage JSON strings. Each plugin entry exports
31
+ // (default or named) `authenticate(credential) -> claims | { deny: true } | null`.
32
+ // We inline the harness as a string (NOT a path import) because the worker runs
33
+ // from the in-memory verified bytes — it has no module graph back to this repo,
34
+ // and a shipped packages/ file must never import a tests/ or scripts/ helper.
35
+ function workerHarness(entrySource) {
36
+ return [
37
+ "const { parentPort } = require('worker_threads');",
38
+ "let __plugin = null;",
39
+ "async function __load() {",
40
+ " if (__plugin) return __plugin;",
41
+ " const mod = await import('data:text/javascript;base64,' + " +
42
+ JSON.stringify(Buffer.from(entrySource, "utf8").toString("base64")) + ");",
43
+ " __plugin = (typeof mod.default === 'function') ? mod.default",
44
+ " : (typeof mod.authenticate === 'function') ? mod.authenticate",
45
+ " : (mod.default && typeof mod.default.authenticate === 'function') ? mod.default.authenticate",
46
+ " : null;",
47
+ " if (typeof __plugin !== 'function') throw new Error('plugin entry must export an authenticate function');",
48
+ " return __plugin;",
49
+ "}",
50
+ "parentPort.on('message', async (raw) => {",
51
+ " let cid = null;",
52
+ " try {",
53
+ " const msg = JSON.parse(raw);",
54
+ " cid = msg.cid;",
55
+ " const authenticate = await __load();",
56
+ " const out = await authenticate(msg.credential);",
57
+ " if (!out || out.deny === true || typeof out !== 'object') {",
58
+ " parentPort.postMessage(JSON.stringify({ cid, deny: true }));",
59
+ " return;",
60
+ " }",
61
+ " parentPort.postMessage(JSON.stringify({ cid, claims: out }));",
62
+ " } catch (err) {",
63
+ // A plugin throw NEVER propagates: it surfaces to the host as a deny.
64
+ " parentPort.postMessage(JSON.stringify({ cid, deny: true }));",
65
+ " }",
66
+ "});"
67
+ ].join("\n");
68
+ }
69
+
70
+ function createSandboxedAuthProviderHandle({
71
+ manifestPath,
72
+ trustAnchors,
73
+ allowCapabilities = [],
74
+ pin = null,
75
+ revoked = {},
76
+ versionFloor = {},
77
+ cryptoProvider,
78
+ auditSink,
79
+ timeoutMs,
80
+ maxPendingCalls = 8,
81
+ maxMessageBytes = 16384,
82
+ resourceLimits,
83
+ coreVersion = null,
84
+ now = Date.now,
85
+ allowedLabelKeys
86
+ } = {}) {
87
+ if (!manifestPath || typeof manifestPath !== "string") {
88
+ throw new Error("createSandboxedAuthProvider requires a manifestPath string");
89
+ }
90
+ if (typeof cryptoProvider?.hmac !== "function") {
91
+ throw new Error("createSandboxedAuthProvider requires a cryptoProvider with hmac()");
92
+ }
93
+ if (!auditSink || typeof auditSink.record !== "function") {
94
+ throw new Error("createSandboxedAuthProvider requires an auditSink with record()");
95
+ }
96
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
97
+ throw new Error("createSandboxedAuthProvider requires a positive integer timeoutMs");
98
+ }
99
+ if (!Number.isInteger(maxPendingCalls) || maxPendingCalls < 1) {
100
+ throw new Error("maxPendingCalls must be a positive integer");
101
+ }
102
+ if (!Number.isInteger(maxMessageBytes) || maxMessageBytes < 1) {
103
+ throw new Error("maxMessageBytes must be a positive integer");
104
+ }
105
+ const nowFn = typeof now === "function" ? now : () => now;
106
+
107
+ // Fire-and-forget audit; lifecycle audit must never make the auth path throw.
108
+ const audit = makeFireAndForgetAudit(auditSink);
109
+
110
+ // Read+validate the manifest + run the FULL PR2 gate. The trust boundary is
111
+ // shared with the process-isolated runtime (packages/plugin/sandbox-common.mjs)
112
+ // so the two sandboxes cannot diverge. Re-run on every (re)spawn — the gate is
113
+ // not a one-time check.
114
+ function loadAndVerify() {
115
+ return loadAndVerifyPlugin({
116
+ manifestPath,
117
+ expectedRuntime: "worker-isolated",
118
+ trustAnchors,
119
+ allowCapabilities,
120
+ pin,
121
+ revoked,
122
+ versionFloor,
123
+ coreVersion,
124
+ now: nowFn(),
125
+ audit
126
+ });
127
+ }
128
+
129
+ // ---- worker lifecycle ----------------------------------------------------
130
+
131
+ let worker = null;
132
+ let pluginId = null;
133
+ let closed = false;
134
+ // cid -> settle(reply). Drops late/duplicate/unmatched replies by cid. Only one
135
+ // entry is ever live at a time (single-occupancy via the serialization chain).
136
+ const pending = new Map();
137
+ let respawning = null; // single-flight respawn guard
138
+ // Serialization chain: worker round-trips run ONE AT A TIME (single-occupancy),
139
+ // so a per-call timeout-terminate can never kill a sibling. queueDepth bounds
140
+ // how many calls may be waiting+running before the worker; excess → deny.
141
+ let chain = Promise.resolve();
142
+ let queueDepth = 0;
143
+
144
+ function spawnFromVerified({ entrySource, pluginId: pid }) {
145
+ const code = workerHarness(entrySource);
146
+ const w = new Worker(code, {
147
+ eval: true,
148
+ resourceLimits,
149
+ // NO host secrets, NO key, NO sink, NO request body cross the boundary.
150
+ workerData: {}
151
+ });
152
+ w.on("message", (raw) => {
153
+ let parsed;
154
+ try {
155
+ parsed = JSON.parse(typeof raw === "string" ? raw : String(raw));
156
+ } catch {
157
+ return; // unparseable → drop
158
+ }
159
+ const cid = parsed?.cid;
160
+ const settle = pending.get(cid);
161
+ if (!settle) {
162
+ return; // unmatched / duplicate / late → drop
163
+ }
164
+ pending.delete(cid);
165
+ settle(parsed);
166
+ });
167
+ // FIX D — stale-worker error race: guard with `worker === w` (same as the
168
+ // exit handler) so a late async error from an already-terminated worker
169
+ // (e.g. from the previous incarnation after a timeout-terminate and respawn)
170
+ // cannot spuriously terminate the live replacement worker.
171
+ w.on("error", () => { if (worker === w) terminateWorker("crash"); });
172
+ w.on("exit", (exitCode) => {
173
+ if (exitCode !== 0 && worker === w) {
174
+ terminateWorker("crash");
175
+ }
176
+ });
177
+ worker = w;
178
+ pluginId = pid;
179
+ }
180
+
181
+ // Drop the live worker (audit the cause), failing any matched in-flight call
182
+ // closed. Respawn happens lazily on the next call (re-running the full gate).
183
+ function terminateWorker(cause) {
184
+ const terminated = worker;
185
+ worker = null;
186
+ if (terminated) {
187
+ audit({ type: "plugin.worker.terminated", decision: "plugin.worker.terminated", pluginId, cause });
188
+ try { terminated.terminate(); } catch { /* already gone */ }
189
+ }
190
+ for (const [, settle] of pending) {
191
+ settle(null);
192
+ }
193
+ pending.clear();
194
+ }
195
+
196
+ // LAZY (re)spawn behind a single-flight guard that RE-RUNS THE FULL PR2 GATE
197
+ // (re-verify signature + anchor + pin + revocation + capabilities + window).
198
+ async function ensureWorker() {
199
+ if (worker || closed) {
200
+ return;
201
+ }
202
+ if (respawning) {
203
+ return respawning;
204
+ }
205
+ respawning = (async () => {
206
+ const loaded = loadAndVerify();
207
+ spawnFromVerified(loaded);
208
+ })();
209
+ try {
210
+ await respawning;
211
+ } finally {
212
+ respawning = null;
213
+ }
214
+ }
215
+
216
+ // One serialized worker round-trip. Resolves to the parsed reply, null (crash /
217
+ // spawn failure), or { __timeout: true }. Runs alone — single-occupancy.
218
+ async function roundTrip(credential) {
219
+ await ensureWorker();
220
+ if (!worker) {
221
+ return null; // spawn failed → fail closed
222
+ }
223
+ const cid = randomUUID();
224
+ const message = JSON.stringify({ cid, credential });
225
+ if (Buffer.byteLength(message, "utf8") > maxMessageBytes) {
226
+ return { __oversized: true };
227
+ }
228
+ return new Promise((resolve) => {
229
+ let done = false;
230
+ const settle = (value) => {
231
+ if (done) return;
232
+ done = true;
233
+ clearTimeout(timer);
234
+ resolve(value);
235
+ };
236
+ const timer = setTimeout(() => {
237
+ pending.delete(cid);
238
+ // Timeout → terminate the worker (audited), deny. Respawn lazily.
239
+ terminateWorker("timeout");
240
+ settle({ __timeout: true });
241
+ }, timeoutMs);
242
+ pending.set(cid, settle);
243
+ try {
244
+ worker.postMessage(message);
245
+ } catch {
246
+ pending.delete(cid);
247
+ settle(null); // worker already dead → fail closed
248
+ }
249
+ });
250
+ }
251
+
252
+ // The sandboxed provider as the conformance harness / proxy see it. It proxies
253
+ // authenticate() into the worker, then the HOST sanitizes + builds the identity.
254
+ // NEVER throws into the caller (catch-all → null).
255
+ async function authenticate(request) {
256
+ try {
257
+ const credential = bearerCredentialFromRequest(request);
258
+ if (credential === null) {
259
+ return null; // missing credential → deny (no worker round-trip needed)
260
+ }
261
+
262
+ // Pending cap: bound concurrency so a burst can never queue unbounded.
263
+ if (queueDepth >= maxPendingCalls) {
264
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "over-capacity" });
265
+ return null;
266
+ }
267
+
268
+ // Serialize: single-occupancy worker. Each call waits its turn; distinct
269
+ // cids guarantee replies never cross even though calls are queued.
270
+ queueDepth += 1;
271
+ const myTurn = chain;
272
+ let release;
273
+ chain = new Promise((r) => { release = r; });
274
+ let reply;
275
+ try {
276
+ await myTurn;
277
+ reply = await roundTrip(credential);
278
+ } finally {
279
+ queueDepth -= 1;
280
+ release();
281
+ }
282
+
283
+ if (reply && reply.__oversized) {
284
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "oversized" });
285
+ return null;
286
+ }
287
+ if (!reply || reply.__timeout) {
288
+ if (reply && reply.__timeout) {
289
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "timeout" });
290
+ }
291
+ return null;
292
+ }
293
+ if (reply.deny === true || reply.claims === undefined) {
294
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "deny" });
295
+ return null;
296
+ }
297
+
298
+ let claims;
299
+ try {
300
+ claims = sanitizeClaims(reply.claims);
301
+ } catch {
302
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "invalid-claims" });
303
+ return null;
304
+ }
305
+
306
+ // The HOST builds the keyed-HMAC identity. The key NEVER crossed to the
307
+ // worker; PII-safety is (re-)enforced here on every call.
308
+ try {
309
+ return await buildExternalIdentity({
310
+ provider: `plugin:${pluginId}`,
311
+ subject: claims.subject,
312
+ issuer: claims.issuer,
313
+ type: claims.type ?? "user",
314
+ scopes: claims.scopes ?? [],
315
+ labels: claims.labels ?? {},
316
+ ...(allowedLabelKeys ? { allowedLabelKeys } : {})
317
+ }, cryptoProvider);
318
+ } catch {
319
+ audit({ type: "plugin.authenticate.deny", decision: "plugin.authenticate.deny", pluginId, reason: "invalid-claims" });
320
+ return null;
321
+ }
322
+ } catch {
323
+ // Catch-all: authenticate NEVER throws into the caller.
324
+ return null;
325
+ }
326
+ }
327
+
328
+ async function close() {
329
+ closed = true;
330
+ const terminated = worker;
331
+ worker = null;
332
+ pending.clear();
333
+ if (terminated) {
334
+ try { await terminated.terminate(); } catch { /* already gone */ }
335
+ }
336
+ }
337
+
338
+ // ---- construct: synchronous load+verify+spawn (PR2 gate throws here), then a
339
+ // one-time async conformance gate. The sync gate runs eagerly so a refused load
340
+ // throws at construction; conformance runs through the SAME worker wire.
341
+
342
+ const initial = loadAndVerify();
343
+ spawnFromVerified(initial);
344
+
345
+ const provider = { id: `plugin:${initial.pluginId}`, authenticate, close };
346
+
347
+ // The conformance run, executed once. Emits load.accepted on pass; on fail it
348
+ // emits load.refused{conformance-failed}, closes the worker, and rejects.
349
+ const conformance = assertAuthProviderConformance(provider, { now: nowFn() })
350
+ .then((result) => {
351
+ if (!result.ok) {
352
+ audit({
353
+ type: "plugin.load.refused",
354
+ decision: "plugin.load.refused",
355
+ reason: "conformance-failed",
356
+ pluginId: initial.pluginId,
357
+ signerKeyId: initial.signerKeyId
358
+ });
359
+ return close().then(() => {
360
+ throw new Error(`plugin conformance failed: ${result.failures.join("; ")}`);
361
+ });
362
+ }
363
+ audit({
364
+ type: "plugin.load.accepted",
365
+ decision: "plugin.load.accepted",
366
+ pluginId: initial.pluginId,
367
+ version: initial.verified.version,
368
+ entrySha256: initial.entrySha256,
369
+ signerKeyId: initial.signerKeyId,
370
+ capabilitiesGranted: Object.entries(initial.verified.capabilities)
371
+ .filter(([, v]) => v === true)
372
+ .map(([k]) => k)
373
+ });
374
+ return provider;
375
+ });
376
+
377
+ // ready resolves when conformance passes / rejects when it fails — the runtime
378
+ // (sync) path awaits this lazily; direct callers await the returned promise.
379
+ provider.ready = conformance;
380
+ return { provider, conformance, pluginId: initial.pluginId, entrySha256: initial.entrySha256, signerKeyId: initial.signerKeyId };
381
+ }
382
+
383
+ // Async factory: resolves to the live provider AFTER conformance passes, rejects
384
+ // on ANY load failure (PR2 gate or conformance). Direct (test) callers await this.
385
+ export async function createSandboxedAuthProvider(options) {
386
+ const { conformance } = createSandboxedAuthProviderHandle(options);
387
+ return conformance;
388
+ }
389
+
390
+ // Synchronous factory for the runtime composition root: the PR2 gate runs eagerly
391
+ // (so a refused load throws at createRuntime time), and conformance is gated
392
+ // lazily behind provider.ready — authenticate() awaits readiness and fails closed
393
+ // (null) if conformance rejected. Returns the host-side authProvider immediately.
394
+ export function createSandboxedAuthProviderSync(options) {
395
+ const { provider, conformance } = createSandboxedAuthProviderHandle(options);
396
+ // Gate readiness on conformance WITHOUT mutating provider.authenticate — the
397
+ // conformance run itself calls provider.authenticate, so wrapping that method
398
+ // in place would make conformance await itself (deadlock). Return a NEW object
399
+ // whose authenticate awaits readiness then delegates to the (untouched) raw
400
+ // provider.authenticate.
401
+ const ready = conformance.then(() => true, () => false);
402
+ return {
403
+ id: provider.id,
404
+ async authenticate(request) {
405
+ if (!(await ready)) {
406
+ return null; // conformance failed → permanently fail closed
407
+ }
408
+ return provider.authenticate(request);
409
+ },
410
+ close() {
411
+ return provider.close();
412
+ },
413
+ ready
414
+ };
415
+ }