@fiscalmindset/blindfold 0.4.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.
package/src/proxy.ts ADDED
@@ -0,0 +1,454 @@
1
+ /**
2
+ * OpenAI-shaped local HTTP proxy.
3
+ *
4
+ * What it does, exactly:
5
+ * - Listens on http://127.0.0.1:<port>.
6
+ * - Accepts any path/method the agent sends.
7
+ * - Reads the agent's headers and body verbatim.
8
+ * - Builds a ForwardRequest whose Authorization header is the sentinel
9
+ * "Bearer __BLINDFOLD__" (NOT whatever the agent sent — that value
10
+ * is discarded; the agent never had the real one anyway).
11
+ * - Sends the ForwardRequest to the T3 contract via `invokeForward`.
12
+ * - Returns the contract's response to the agent.
13
+ *
14
+ * What it does NOT do:
15
+ * - Read any secret from disk or env.
16
+ * - Cache responses by anything containing a secret.
17
+ * - Log header values (it uses `safeLog`, which scrubs them).
18
+ *
19
+ * The "one line" the developer changes is:
20
+ * OPENAI_BASE_URL=http://127.0.0.1:<port>/v1
21
+ *
22
+ * Routing + per-provider auth live in `providers.ts`. Each concrete provider
23
+ * declares its upstream host, sealed-secret name, and auth scheme (bearer /
24
+ * basic / sigv4). For basic and sigv4 the enclave *computes* the Authorization
25
+ * from the sealed secret — the raw secret never appears in a header on its own.
26
+ */
27
+ import http from "node:http";
28
+ import crypto from "node:crypto";
29
+ import fs from "node:fs";
30
+ import net from "node:net";
31
+ import type { AddressInfo } from "node:net";
32
+ import { SENTINEL } from "./constants.ts";
33
+ import { loadBlindfoldEnv } from "./env.ts";
34
+ import { safeLog } from "./log.ts";
35
+ import { openT3Client, T3TimeoutError, type T3ClientHandle } from "./t3-client.ts";
36
+ import type { BlindfoldEnv, ForwardRequest } from "./types.ts";
37
+ import { logUsage, providerForUpstream } from "./usage-log.ts";
38
+ import { resolveProvider } from "./providers.ts";
39
+
40
+ export interface ProxyOpts {
41
+ port?: number;
42
+ /** Logical name of the secret to substitute. Default: "openai_api_key". */
43
+ secretKey?: string;
44
+ /**
45
+ * Per-session auth token. When set (non-empty), every request except
46
+ * `/health` must carry it in the `x-blindfold-token` header — so only the
47
+ * process that was handed this token (the agent Blindfold wraps) can use the
48
+ * proxy, not just any co-resident local process. Falls back to the
49
+ * `BLINDFOLD_PROXY_TOKEN` env var. Omit/empty ⇒ no auth (back-compat).
50
+ */
51
+ token?: string;
52
+ /**
53
+ * Bind a unix-domain socket at this filesystem path instead of a TCP port.
54
+ * The socket file is created with 0600 permissions, so only processes running
55
+ * as the same OS user can even connect — closing the "any local process can
56
+ * reach 127.0.0.1" gap at the OS level. When set, `port` is ignored.
57
+ * (Client support: `curl --unix-socket <path>`; SDK-over-socket is a follow-up.)
58
+ */
59
+ socket?: string;
60
+ }
61
+
62
+ // Cap the proxied request body so a huge upload can't exhaust proxy memory.
63
+ const MAX_BODY_BYTES = Number(process.env.BLINDFOLD_MAX_BODY_BYTES) || 5 * 1024 * 1024;
64
+ // Idle-read timeout so a slow-loris client can't hold a request open forever (L4).
65
+ const BODY_IDLE_MS = Number(process.env.BLINDFOLD_BODY_IDLE_MS) || 30_000;
66
+ // Bound concurrent enclave calls so a burst can't fan out unbounded to the node
67
+ // (S1). Excess requests get 503/Retry-After instead of piling on.
68
+ const MAX_INFLIGHT = Number(process.env.BLINDFOLD_MAX_INFLIGHT) || 64;
69
+ let inflight = 0;
70
+ class PayloadTooLargeError extends Error {}
71
+
72
+ /** Constant-time string compare (avoids leaking the token via timing). */
73
+ function tokenMatches(provided: string, expected: string): boolean {
74
+ const a = Buffer.from(provided);
75
+ const b = Buffer.from(expected);
76
+ if (a.length !== b.length) return false;
77
+ return crypto.timingSafeEqual(a, b);
78
+ }
79
+
80
+ export const PROXY_TOKEN_HEADER = "x-blindfold-token";
81
+
82
+ /** Resolve true if a LIVE server is already accepting on this unix socket
83
+ * (so we don't unlink a socket another instance is serving). ECONNREFUSED /
84
+ * missing file ⇒ stale ⇒ safe to remove. */
85
+ function isSocketLive(socketPath: string): Promise<boolean> {
86
+ return new Promise((resolve) => {
87
+ if (!fs.existsSync(socketPath)) { resolve(false); return; }
88
+ const c = net.connect(socketPath);
89
+ const done = (live: boolean) => { try { c.destroy(); } catch { /* ignore */ } resolve(live); };
90
+ c.once("connect", () => done(true));
91
+ c.once("error", () => done(false));
92
+ c.setTimeout(500, () => done(false));
93
+ });
94
+ }
95
+
96
+ export interface ProxyHandle {
97
+ url: string;
98
+ port: number;
99
+ /** The active per-session token, if auth is enabled (else undefined). */
100
+ token?: string;
101
+ /** The unix-domain socket path, if bound to one instead of a TCP port. */
102
+ socket?: string;
103
+ close: () => Promise<void>;
104
+ }
105
+
106
+ export async function startProxy(opts: ProxyOpts = {}): Promise<ProxyHandle> {
107
+ const env = loadBlindfoldEnv();
108
+ const port = opts.port ?? env.port ?? 8787;
109
+ const secretKey = opts.secretKey ?? "openai_api_key";
110
+ const token = (opts.token ?? process.env.BLINDFOLD_PROXY_TOKEN ?? "").trim() || undefined;
111
+ const socketPath = opts.socket?.trim() || undefined;
112
+
113
+ // Refuse to clobber a socket a LIVE instance is already serving (L3). A stale
114
+ // file (ECONNREFUSED) is fine to replace.
115
+ if (socketPath && (await isSocketLive(socketPath))) {
116
+ throw new Error(`a Blindfold proxy is already listening on ${socketPath} — stop it first, or use a different --socket path`);
117
+ }
118
+
119
+ const t3 = await openT3Client(env);
120
+
121
+ const server = http.createServer((req, res) => {
122
+ handle(req, res, t3, secretKey, env, token).catch((e) => {
123
+ if (e instanceof PayloadTooLargeError) {
124
+ if (!res.headersSent) res.writeHead(413, { "content-type": "text/plain", "connection": "close" });
125
+ res.end("request body too large");
126
+ req.destroy();
127
+ return;
128
+ }
129
+ safeLog("error", { msg: "proxy_unhandled", error: (e as Error).message });
130
+ if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
131
+ res.end("internal proxy error");
132
+ });
133
+ });
134
+
135
+ return await new Promise<ProxyHandle>((resolve, reject) => {
136
+ server.once("error", reject);
137
+
138
+ let prevUmask: number | undefined;
139
+ const onListening = () => {
140
+ let url: string;
141
+ let bound = 0;
142
+ if (socketPath) {
143
+ // The umask below already birthed the socket as 0600 (closing the
144
+ // bind→chmod TOCTOU window, H5); re-chmod as belt-and-suspenders and
145
+ // restore the process umask.
146
+ try { fs.chmodSync(socketPath, 0o600); } catch { /* best-effort */ }
147
+ if (prevUmask !== undefined) { try { process.umask(prevUmask); } catch { /* ignore */ } }
148
+ url = `unix:${socketPath}`;
149
+ } else {
150
+ bound = (server.address() as AddressInfo).port;
151
+ url = `http://127.0.0.1:${bound}`;
152
+ }
153
+ safeLog("info", { msg: "proxy_listening", url, mock: env.mock });
154
+ resolve({
155
+ url,
156
+ port: bound,
157
+ token,
158
+ socket: socketPath,
159
+ close: async () => {
160
+ await new Promise<void>((r) => server.close(() => r()));
161
+ if (socketPath) { try { fs.rmSync(socketPath, { force: true }); } catch { /* ignore */ } }
162
+ await t3.close();
163
+ },
164
+ });
165
+ };
166
+
167
+ if (socketPath) {
168
+ // Remove a stale socket file from a previous run, then bind the socket
169
+ // PRIVATE from birth: umask 0177 → new files are 0600, so there is no
170
+ // window where the socket is world-connectable before chmod (H5).
171
+ try { fs.rmSync(socketPath, { force: true }); } catch { /* ignore */ }
172
+ prevUmask = process.umask(0o177);
173
+ server.listen(socketPath, onListening);
174
+ } else {
175
+ server.listen(port, "127.0.0.1", onListening);
176
+ }
177
+ });
178
+ }
179
+
180
+ async function handle(
181
+ req: http.IncomingMessage,
182
+ res: http.ServerResponse,
183
+ t3: T3ClientHandle,
184
+ secretKey: string,
185
+ env: BlindfoldEnv,
186
+ token?: string,
187
+ ): Promise<void> {
188
+ if (req.url === "/health") {
189
+ res.writeHead(200, { "content-type": "application/json" });
190
+ res.end(JSON.stringify({ ok: true, mock: env.mock, auth: Boolean(token) }));
191
+ return;
192
+ }
193
+
194
+ // Per-session auth: when a token is configured, only a caller that presents it
195
+ // may use the proxy. This gates *use* by a co-resident local process — the
196
+ // enclave still guarantees the key can never be *stolen* either way.
197
+ if (token) {
198
+ const provided = String(req.headers[PROXY_TOKEN_HEADER] ?? "");
199
+ if (!provided || !tokenMatches(provided, token)) {
200
+ safeLog("warn", { msg: "proxy_unauthorized", path: req.url });
201
+ res.writeHead(401, { "content-type": "text/plain" });
202
+ res.end(`unauthorized: missing or invalid ${PROXY_TOKEN_HEADER} header`);
203
+ return;
204
+ }
205
+ }
206
+
207
+ const provider = resolveProvider(req.url ?? "/");
208
+ if (!provider) {
209
+ res.writeHead(404, { "content-type": "text/plain" });
210
+ res.end(`no upstream mapping for ${req.url}`);
211
+ return;
212
+ }
213
+ const upstream = provider.upstream;
214
+ // Per-provider sealed secret. LLM providers fall back to the proxy default
215
+ // (preserves the original single-key behaviour); Stripe/Twilio/AWS/etc. each
216
+ // name their own sealed secret.
217
+ const providerSecretKey = provider.secretKey ?? secretKey;
218
+
219
+ const body = await readBody(req, MAX_BODY_BYTES);
220
+ // Build the headers we'll send to the contract. Any Authorization the agent
221
+ // sent is discarded. For bearer providers we plant the sentinel for the
222
+ // enclave to swap; for basic/sigv4 the enclave BUILDS the Authorization from
223
+ // the sealed secret, so we send no auth header at all.
224
+ const agentSuppliedAuth = Object.keys(req.headers).some((k) => k.toLowerCase() === "authorization");
225
+ const headers = forwardableHeaders(req.headers);
226
+ if (provider.auth.scheme === "bearer") {
227
+ // Discard whatever the agent sent; plant the sentinel where this provider
228
+ // expects its key (Authorization: Bearer … by default, or e.g. Gemini's
229
+ // x-goog-api-key). The enclave swaps the sentinel for the real secret.
230
+ const sh = provider.sentinelHeader ?? { name: "authorization", prefix: "Bearer " };
231
+ removeHeader(headers, "authorization");
232
+ ensureHeader(headers, sh.name, `${sh.prefix}${SENTINEL}`);
233
+ } else {
234
+ // basic/sigv4: the enclave computes the whole Authorization from the sealed
235
+ // secret; strip any agent-sent one so nothing stale rides along.
236
+ removeHeader(headers, "authorization");
237
+ }
238
+
239
+ // Inject this provider's real required headers (e.g. GitHub's mandatory
240
+ // User-Agent, Anthropic's anthropic-version, Stripe's pinned API version) —
241
+ // only when the agent didn't set them, so the agent can still override. This
242
+ // is what makes each provider a real integration rather than a bare host.
243
+ for (const [name, value] of Object.entries(provider.defaultHeaders ?? {})) {
244
+ if (!headers.some(([k]) => k.toLowerCase() === name.toLowerCase())) {
245
+ headers.push([name, value]);
246
+ }
247
+ }
248
+
249
+ // Work around the T3 host egress parsing every request body as JSON (a raw
250
+ // form-encoded body fails with `http.parse_payload: expected value…`).
251
+ // Form-encoded APIs (Stripe, Twilio, AWS query APIs) accept the same params
252
+ // in the query string, so move a form body into the URL and send no body.
253
+ // The agent's code is unchanged — it can POST a normal form and this adapts
254
+ // it. JSON bodies are left untouched (the host parses those fine).
255
+ let outboundUrl = upstream;
256
+ let outboundBody: string | undefined = body.length ? body.toString("utf8") : undefined;
257
+ const contentType = (headers.find(([k]) => k.toLowerCase() === "content-type")?.[1] ?? "").toLowerCase();
258
+ // Skip the form→query rewrite for webhook providers: the URL is the sentinel
259
+ // (the enclave substitutes the real URL), so it isn't parseable here.
260
+ if (provider.auth.scheme !== "webhook" && outboundBody && contentType.includes("application/x-www-form-urlencoded")) {
261
+ const u = new URL(outboundUrl);
262
+ for (const [k, v] of new URLSearchParams(outboundBody)) u.searchParams.append(k, v);
263
+ outboundUrl = u.toString();
264
+ outboundBody = undefined;
265
+ safeLog("info", { msg: "form_body_to_query", provider: provider.id });
266
+ }
267
+
268
+ const forwardReq: ForwardRequest = {
269
+ method: req.method ?? "GET",
270
+ url: outboundUrl,
271
+ headers,
272
+ body: outboundBody,
273
+ secret_key: providerSecretKey,
274
+ auth: provider.auth,
275
+ };
276
+
277
+ safeLog("info", {
278
+ msg: "proxy_forward",
279
+ method: forwardReq.method,
280
+ upstream: upstream.replace(/\?.*$/, ""),
281
+ });
282
+
283
+ // Backpressure: don't fan out unbounded concurrent enclave calls (S1).
284
+ if (inflight >= MAX_INFLIGHT) {
285
+ safeLog("warn", { msg: "proxy_saturated", inflight, max: MAX_INFLIGHT });
286
+ if (!res.headersSent) res.writeHead(503, { "content-type": "text/plain", "retry-after": "1" });
287
+ res.end("proxy saturated: too many concurrent enclave calls — retry shortly");
288
+ return;
289
+ }
290
+
291
+ const startedAt = Date.now();
292
+ let result;
293
+ inflight++;
294
+ try {
295
+ result = await t3.invokeForward(forwardReq);
296
+ } catch (e) {
297
+ // Surface the REAL enclave/host error (egress denied, rate limit, secrets
298
+ // ACL, payload parse) with an actionable hint — not a generic 500.
299
+ const { status, body } = explainForwardError(e as Error);
300
+ safeLog("error", { msg: "proxy_forward_failed", status, provider: provider.id, error: (e as Error).message });
301
+ if (!res.headersSent) res.writeHead(status, { "content-type": "application/json" });
302
+ res.end(body);
303
+ return;
304
+ } finally {
305
+ inflight--;
306
+ }
307
+ const latency = Date.now() - startedAt;
308
+
309
+ // Record non-sensitive telemetry. Never the body, never the header values.
310
+ logUsage({
311
+ t: new Date().toISOString(),
312
+ mode: env.mock ? "mock" : "real",
313
+ provider: provider.id || providerForUpstream(upstream),
314
+ method: forwardReq.method,
315
+ path: req.url ?? "/",
316
+ upstream: upstream.replace(/\?.*$/, ""),
317
+ status: result.status,
318
+ latency_ms: latency,
319
+ agent_supplied_auth: agentSuppliedAuth,
320
+ auth_scheme: provider.auth.scheme,
321
+ sentinel_in_outbound: forwardReq.headers.some(([k, v]) => k.toLowerCase() === "authorization" && v.includes(SENTINEL)),
322
+ via: "proxy",
323
+ secret_key: providerSecretKey,
324
+ });
325
+
326
+ res.writeHead(result.status, headersFromTuple(result.headers));
327
+ const bodyBytes = typeof result.body === "string" ? Buffer.from(result.body, "utf8") : Buffer.from(result.body);
328
+ res.end(bodyBytes);
329
+ }
330
+
331
+ function readBody(req: http.IncomingMessage, maxBytes: number): Promise<Buffer> {
332
+ return new Promise((resolve, reject) => {
333
+ const chunks: Buffer[] = [];
334
+ let size = 0;
335
+ let aborted = false;
336
+ let timer: ReturnType<typeof setTimeout>;
337
+ const fail = (e: Error) => { aborted = true; clearTimeout(timer); reject(e); };
338
+ const arm = () => {
339
+ clearTimeout(timer);
340
+ timer = setTimeout(() => { if (!aborted) fail(new Error(`request body read timed out after ${BODY_IDLE_MS}ms`)); }, BODY_IDLE_MS);
341
+ };
342
+ arm();
343
+ req.on("data", (c: Buffer) => {
344
+ if (aborted) return;
345
+ arm(); // reset the idle timer on progress
346
+ size += c.length;
347
+ if (size > maxBytes) {
348
+ // Stop accumulating; the handler sends 413 then destroys the socket.
349
+ fail(new PayloadTooLargeError(`request body exceeds ${maxBytes} bytes`));
350
+ return;
351
+ }
352
+ chunks.push(c);
353
+ });
354
+ req.on("end", () => { clearTimeout(timer); if (!aborted) resolve(Buffer.concat(chunks)); });
355
+ req.on("error", (e) => { if (!aborted) fail(e); });
356
+ });
357
+ }
358
+
359
+ const STRIPPED = new Set(["host", "content-length", "connection", "transfer-encoding"]);
360
+
361
+ function forwardableHeaders(h: http.IncomingHttpHeaders): Array<[string, string]> {
362
+ const out: Array<[string, string]> = [];
363
+ for (const [k, v] of Object.entries(h)) {
364
+ if (v === undefined) continue;
365
+ if (STRIPPED.has(k.toLowerCase())) continue;
366
+ out.push([k, Array.isArray(v) ? v.join(", ") : v]);
367
+ }
368
+ return out;
369
+ }
370
+
371
+ function ensureHeader(h: Array<[string, string]>, name: string, value: string): void {
372
+ const lower = name.toLowerCase();
373
+ const idx = h.findIndex(([k]) => k.toLowerCase() === lower);
374
+ if (idx >= 0) h[idx] = [name, value];
375
+ else h.push([name, value]);
376
+ }
377
+
378
+ function removeHeader(h: Array<[string, string]>, name: string): void {
379
+ const lower = name.toLowerCase();
380
+ for (let i = h.length - 1; i >= 0; i--) {
381
+ if (h[i]![0].toLowerCase() === lower) h.splice(i, 1);
382
+ }
383
+ }
384
+
385
+ function headersFromTuple(t: Array<[string, string]>): Record<string, string> {
386
+ const out: Record<string, string> = {};
387
+ for (const [k, v] of t) out[k] = v;
388
+ return out;
389
+ }
390
+
391
+ /**
392
+ * Turn a raw T3 forward error into a real HTTP status + a JSON body that names
393
+ * the cause and how to fix it. The enclave/host errors look like:
394
+ * HTTP 400: Invalid params ({"code":"bad_request","detail":"…","request_id":"…"})
395
+ * These are the exact failures that cost real diagnosis time when hidden behind
396
+ * a generic "internal proxy error".
397
+ */
398
+ function explainForwardError(err: Error): { status: number; body: string } {
399
+ const msg = err?.message ?? String(err);
400
+ if (err instanceof T3TimeoutError) {
401
+ return {
402
+ status: 504,
403
+ body: JSON.stringify({
404
+ error: "blindfold_forward_failed",
405
+ status: 504,
406
+ code: "t3_timeout",
407
+ detail: msg,
408
+ hint: "The T3 node did not respond within the deadline (BLINDFOLD_T3_TIMEOUT_MS). It may be slow or unreachable; retry, or point T3_BASE_URL at a healthy node.",
409
+ }, null, 2),
410
+ };
411
+ }
412
+ let status = Number(msg.match(/HTTP (\d{3})/)?.[1]) || 502;
413
+ let code = "";
414
+ let detail = msg;
415
+ let requestId = "";
416
+ const jsonM = msg.match(/\((\{.*\})\)\s*$/);
417
+ if (jsonM && jsonM[1]) {
418
+ try {
419
+ const o = JSON.parse(jsonM[1]) as Record<string, string>;
420
+ code = o.code ?? "";
421
+ detail = o.detail ?? detail;
422
+ requestId = o.request_id ?? "";
423
+ } catch {
424
+ /* keep the raw message */
425
+ }
426
+ }
427
+
428
+ let hint = "";
429
+ if (/egress_denied|authorised_hosts|allowlist/i.test(detail)) {
430
+ const host = detail.match(/host '([^']+)'/)?.[1];
431
+ hint = `Egress is not authorized${host ? ` for '${host}'` : ""}. Run: blindfold grant --host ${host ?? "<host>"} — list every host you use in ONE command (grant replaces the allowlist unless merged).`;
432
+ if (status < 400) status = 403;
433
+ } else if (/fuel_per_minute|too_many_requests|rate limit/i.test(detail)) {
434
+ hint = "Rate limited by the testnet per-minute compute quota (fuel_per_minute). Retry in ~60s and space calls out — this is not an outage.";
435
+ status = 429;
436
+ } else if (/cannot read map|:secrets/i.test(detail)) {
437
+ hint = "The contract isn't authorized to read your secrets map (common right after publishing a new contract id). Run: blindfold init (re-grants the secrets read ACL).";
438
+ } else if (/parse_payload|expected value at line/i.test(detail)) {
439
+ hint = "The T3 host egress parses request bodies as JSON. For form-encoded APIs (Stripe/Twilio), send params in the query string with an empty body.";
440
+ if (status < 400) status = 400;
441
+ } else if (/secret .* not found|not found in the secrets map/i.test(detail)) {
442
+ hint = "That sealed secret name doesn't exist. Seal it: blindfold register --name <name> --from-env <ENV_VAR>.";
443
+ }
444
+
445
+ const payload = {
446
+ error: "blindfold_forward_failed",
447
+ status,
448
+ code: code || undefined,
449
+ detail,
450
+ request_id: requestId || undefined,
451
+ hint: hint || undefined,
452
+ };
453
+ return { status, body: JSON.stringify(payload, null, 2) };
454
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ⚠️ AUDIT-CRITICAL FILE
3
+ *
4
+ * This is the ONLY file in Blindfold that ever has the plaintext API
5
+ * key in scope. It exists so the auditor can read it end-to-end in one
6
+ * sitting:
7
+ *
8
+ * 1. Read the value from process.env via the helper `pluckSecret`.
9
+ * 2. Pass it as the `value` field of a single `seedSecret` call.
10
+ * 3. Return.
11
+ *
12
+ * The value is not stored on disk, not echoed to logs, not returned by
13
+ * this function, and not assigned to any module-level state. The local
14
+ * binding `value` goes out of scope at function exit.
15
+ *
16
+ * Any change to this file should be reviewed against `docs/AGENTS.md`
17
+ * invariant #2.
18
+ */
19
+ import { loadBlindfoldEnv, pluckSecret } from "./env.ts";
20
+ import { safeLog } from "./log.ts";
21
+ import { readSecretLine } from "./prompt.ts";
22
+ import { recordSealed } from "./sealed-ledger.ts";
23
+ import { openT3Client } from "./t3-client.ts";
24
+ import { SENTINEL } from "./constants.ts";
25
+ import type { RegisterOpts } from "./types.ts";
26
+
27
+ export async function registerSecret(opts: RegisterOpts): Promise<void> {
28
+ const env = loadBlindfoldEnv();
29
+ const t3 = await openT3Client(env);
30
+
31
+ try {
32
+ // Resolve the plaintext exactly once. Three input modes, in priority:
33
+ // 1. opts.value — explicit (programmatic API)
34
+ // 2. opts.fromEnv — read process.env[<name>]
35
+ // 3. interactive — prompt the terminal with echo disabled
36
+ // The local binding `value` exists here, is passed to seedSecret, then dropped.
37
+ let source = "stdin";
38
+ let value: string;
39
+ if (opts.value !== undefined) {
40
+ value = opts.value;
41
+ source = "explicit";
42
+ } else if (opts.fromEnv) {
43
+ value = pluckSecret(opts.fromEnv);
44
+ source = `env:${opts.fromEnv}`;
45
+ } else {
46
+ value = await readSecretLine(` Value for "${opts.name}" (input is hidden): `);
47
+ if (!value) throw new Error("empty value");
48
+ }
49
+
50
+ if (value.includes(SENTINEL)) {
51
+ throw new Error(`Secret value contains the Blindfold sentinel string "${SENTINEL}" — this would cause infinite substitution. Use a different value.`);
52
+ }
53
+
54
+ await t3.seedSecret(opts.name, value);
55
+ const length = value.length;
56
+ const mode = env.mock ? "mock" : "real";
57
+ // Append to the sealed-keys ledger — metadata only, never the value.
58
+ recordSealed({
59
+ t: new Date().toISOString(),
60
+ name: opts.name,
61
+ source,
62
+ length,
63
+ mode,
64
+ tenant_did: env.did,
65
+ map_name: env.did ? `z:${env.did.replace(/^did:t3n:/, "")}:secrets` : "(mock)",
66
+ });
67
+ safeLog("info", { msg: "registered", name: opts.name, source, length, mode });
68
+ } finally {
69
+ await t3.close();
70
+ }
71
+ }
72
+
73
+ export async function registerContract(wasm: Uint8Array): Promise<{ contractId: string | number }> {
74
+ const env = loadBlindfoldEnv();
75
+ const t3 = await openT3Client(env);
76
+ try {
77
+ return await t3.registerContract(wasm);
78
+ } finally {
79
+ await t3.close();
80
+ }
81
+ }
package/src/release.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * release() — short-lived secret retrieval from the T3 enclave.
3
+ *
4
+ * for short-lived use — drop the returned value from scope as soon as the call is done.
5
+ *
6
+ * This is the canonical one-liner alternative to the ~30-line release-broker
7
+ * pattern shown in examples/grok-via-blindfold.ts. It opens a T3 client,
8
+ * calls the "release-to-tenant" contract function, returns the plaintext
9
+ * value, then closes the client. The caller is responsible for not persisting
10
+ * the returned string.
11
+ *
12
+ * SECURITY NOTE: the returned value is a plaintext secret. Treat it like a
13
+ * raw API key — do not log it, do not store it on disk, drop it from scope
14
+ * as soon as the outbound call it enables completes.
15
+ */
16
+ import { loadBlindfoldEnv } from "./env.ts";
17
+ import { CONTRACT_TAIL, CONTRACT_VERSION } from "./constants.ts";
18
+ import { openT3Client, type T3ClientHandle } from "./t3-client.ts";
19
+ import { logUsage } from "./usage-log.ts";
20
+ import type { BlindfoldEnv } from "./types.ts";
21
+
22
+ // Reuse one opened client per (tenant, node) for the process lifetime. Opening
23
+ // a client does a WASM load + handshake + authenticate (3–4 round-trips); doing
24
+ // that on every release() is the latency amplification flagged in review.
25
+ const clientCache = new Map<string, Promise<T3ClientHandle>>();
26
+ function sharedClient(env: BlindfoldEnv): Promise<T3ClientHandle> {
27
+ const key = `${env.did}|${env.t3Env}|${env.t3BaseUrl}|${env.mock ? 1 : 0}`;
28
+ let c = clientCache.get(key);
29
+ if (!c) {
30
+ c = openT3Client(env).catch((e) => { clientCache.delete(key); throw e; });
31
+ clientCache.set(key, c);
32
+ }
33
+ return c;
34
+ }
35
+
36
+ export interface ReleaseOpts {
37
+ /** Override the T3 client env (useful in tests). If omitted, loadBlindfoldEnv() is used. */
38
+ env?: BlindfoldEnv;
39
+ /** How this release was triggered — recorded in the usage log ("release" | "use" | "export"). */
40
+ via?: string;
41
+ }
42
+
43
+ /**
44
+ * Release a sealed secret from the T3 enclave by name and return its
45
+ * plaintext value.
46
+ *
47
+ * for short-lived use — drop the returned value from scope as soon as the call is done.
48
+ *
49
+ * @param name The secret name passed to `registerSecret` when the key was sealed.
50
+ * @param opts Optional overrides (env, t3Client).
51
+ * @returns The plaintext secret value.
52
+ */
53
+ export async function release(name: string, opts?: ReleaseOpts): Promise<string> {
54
+ const env = opts?.env ?? loadBlindfoldEnv();
55
+ // Use the shared, memoized client on the common runtime path; when the caller
56
+ // supplies an explicit env (tests), open a dedicated client and close it.
57
+ const useShared = !opts?.env;
58
+ const t3 = useShared ? await sharedClient(env) : await openT3Client(env);
59
+ const startedAt = Date.now();
60
+ try {
61
+ const value = await t3.releaseSecret(name);
62
+ // Record the release in the usage log (metadata only — never the value) so
63
+ // the dashboard reflects ALL secret use, not just proxy traffic.
64
+ logUsage({
65
+ t: new Date().toISOString(),
66
+ mode: env.mock ? "mock" : "real",
67
+ provider: "(enclave)",
68
+ method: "RELEASE",
69
+ path: name,
70
+ upstream: "t3-enclave",
71
+ status: 200,
72
+ latency_ms: Date.now() - startedAt,
73
+ agent_supplied_auth: false,
74
+ sentinel_in_outbound: false,
75
+ via: opts?.via ?? "release",
76
+ secret_key: name,
77
+ });
78
+ return value;
79
+ } finally {
80
+ if (!useShared) await t3.close();
81
+ }
82
+ }