@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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Append-only usage log for the Blindfold proxy.
3
+ *
4
+ * SAFETY: this module records ONLY non-sensitive metadata. It never
5
+ * writes request bodies, response bodies, or any header values to disk.
6
+ * The dashboard and stats commands read from the same file and inherit
7
+ * this constraint by construction.
8
+ *
9
+ * Default path: ./.blindfold/usage.jsonl (overridable via env BLINDFOLD_USAGE_LOG)
10
+ *
11
+ * Format: one JSON object per line (JSONL). Schema below.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { stateDir } from "./env.ts";
16
+
17
+ export interface UsageEvent {
18
+ /** ISO-8601 timestamp */
19
+ t: string;
20
+ /** "real" if a T3 contract was called, "mock" if the local stub responded */
21
+ mode: "real" | "mock";
22
+ /** Provider derived from the URL, e.g. "openai", "anthropic" */
23
+ provider: string;
24
+ /** HTTP method the agent used */
25
+ method: string;
26
+ /** Path the agent hit on the proxy (e.g. /v1/chat/completions) */
27
+ path: string;
28
+ /** Upstream URL the contract was asked to call */
29
+ upstream: string;
30
+ /** Response status code */
31
+ status: number;
32
+ /** End-to-end latency through the proxy (ms) */
33
+ latency_ms: number;
34
+ /** True if the agent supplied any Authorization header at all (interesting telemetry: did it think it had a key?) */
35
+ agent_supplied_auth: boolean;
36
+ /** True iff the outbound request carries the Blindfold sentinel — proof the proxy did its job */
37
+ sentinel_in_outbound: boolean;
38
+ /** Auth scheme the enclave applied: "bearer" | "basic" | "sigv4". Optional for back-compat. */
39
+ auth_scheme?: string;
40
+ /** How the secret was used: "proxy" (HTTP proxy) | "release"/"use"/"export" (broker paths). Optional for back-compat. */
41
+ via?: string;
42
+ /** The sealed secret name involved (for the per-secret view). */
43
+ secret_key?: string;
44
+ }
45
+
46
+ export function defaultLogPath(): string {
47
+ return process.env.BLINDFOLD_USAGE_LOG ?? path.join(stateDir(), "usage.jsonl");
48
+ }
49
+
50
+ const USAGE_MAX_BYTES = Number(process.env.BLINDFOLD_USAGE_MAX_BYTES) || 10 * 1024 * 1024;
51
+
52
+ /** Rotate usage.jsonl → .1 → .2 when it grows past the size cap. */
53
+ function rotateIfNeeded(p: string): void {
54
+ try {
55
+ const size = fs.statSync(p).size;
56
+ if (size < USAGE_MAX_BYTES) return;
57
+ if (fs.existsSync(`${p}.1`)) fs.renameSync(`${p}.1`, `${p}.2`);
58
+ fs.renameSync(p, `${p}.1`);
59
+ } catch {
60
+ /* stat/rename failures must not break the request path */
61
+ }
62
+ }
63
+
64
+ // Hot-path telemetry must not block the event loop (S4): ensure the dir once,
65
+ // throttle the rotation stat, and append asynchronously (fire-and-forget).
66
+ const ensuredDirs = new Set<string>();
67
+ const ROTATE_CHECK_EVERY = 500;
68
+ let writesSinceRotateCheck = ROTATE_CHECK_EVERY; // check on the first write
69
+
70
+ export function logUsage(event: UsageEvent): void {
71
+ const p = defaultLogPath();
72
+ try {
73
+ const dir = path.dirname(p);
74
+ if (!ensuredDirs.has(dir)) { fs.mkdirSync(dir, { recursive: true }); ensuredDirs.add(dir); }
75
+ if (++writesSinceRotateCheck >= ROTATE_CHECK_EVERY) { writesSinceRotateCheck = 0; rotateIfNeeded(p); }
76
+ fs.appendFile(p, JSON.stringify(event) + "\n", () => { /* never throw on the request path */ });
77
+ } catch {
78
+ // Logging is never allowed to throw and crash a request path.
79
+ }
80
+ }
81
+
82
+ export function readUsage(): UsageEvent[] {
83
+ const p = defaultLogPath();
84
+ if (!fs.existsSync(p)) return [];
85
+ return fs
86
+ .readFileSync(p, "utf8")
87
+ .split("\n")
88
+ .filter(Boolean)
89
+ .map((line) => {
90
+ try {
91
+ return JSON.parse(line) as UsageEvent;
92
+ } catch {
93
+ return null;
94
+ }
95
+ })
96
+ .filter((e): e is UsageEvent => e !== null);
97
+ }
98
+
99
+ /**
100
+ * Read only the last `n` events (byte-tail read), so a polled server never
101
+ * loads a multi-hundred-MB log into memory. Falls back to whole-file only for
102
+ * small files.
103
+ */
104
+ export function readUsageTail(n = 500): UsageEvent[] {
105
+ const p = defaultLogPath();
106
+ if (!fs.existsSync(p)) return [];
107
+ const fd = fs.openSync(p, "r");
108
+ try {
109
+ const size = fs.fstatSync(fd).size;
110
+ // Read at most ~2KB per requested event from the end of the file.
111
+ const readLen = Math.min(size, Math.max(64 * 1024, n * 2048));
112
+ const buf = Buffer.alloc(readLen);
113
+ fs.readSync(fd, buf, 0, readLen, size - readLen);
114
+ let text = buf.toString("utf8");
115
+ if (readLen < size) {
116
+ // Drop a possibly-partial first line.
117
+ const nl = text.indexOf("\n");
118
+ if (nl >= 0) text = text.slice(nl + 1);
119
+ }
120
+ const events = text
121
+ .split("\n")
122
+ .filter(Boolean)
123
+ .map((line) => { try { return JSON.parse(line) as UsageEvent; } catch { return null; } })
124
+ .filter((e): e is UsageEvent => e !== null);
125
+ return events.slice(-n);
126
+ } finally {
127
+ fs.closeSync(fd);
128
+ }
129
+ }
130
+
131
+ export function clearUsage(): void {
132
+ const p = defaultLogPath();
133
+ if (fs.existsSync(p)) fs.unlinkSync(p);
134
+ }
135
+
136
+ export function providerForUpstream(upstream: string): string {
137
+ try {
138
+ const u = new URL(upstream);
139
+ const h = u.hostname;
140
+ if (h.endsWith("openai.com")) return "openai";
141
+ if (h.endsWith("anthropic.com")) return "anthropic";
142
+ if (h.endsWith("googleapis.com")) return "google";
143
+ if (h.endsWith("x.ai")) return "xai";
144
+ if (h.endsWith("groq.com")) return "groq";
145
+ return h;
146
+ } catch {
147
+ return "unknown";
148
+ }
149
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Local index of secret versions for rollback.
3
+ *
4
+ * When you `rotate` a secret, the PREVIOUS value is snapshotted into the enclave
5
+ * under a reserved key (`__bfver__<name>__<ts>`) — the value never leaves the
6
+ * enclave; only this metadata index is local. `rollback` reads the snapshot back
7
+ * and re-seals it under the live name.
8
+ *
9
+ * SAFETY: metadata only (name, the enclave version-key, byte-length, a
10
+ * non-reversible fingerprint, timestamp). Never the value. The reserved version
11
+ * keys are NOT recorded in the sealed-keys ledger, so `status`/`audit` ignore them.
12
+ *
13
+ * Default path: ./.blindfold/versions.jsonl (override via BLINDFOLD_VERSIONS_LOG)
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { stateDir } from "./env.ts";
18
+
19
+ export interface VersionEntry {
20
+ t: string; // ISO timestamp the snapshot was taken
21
+ name: string; // the live secret name this is a version of
22
+ versionKey: string; // reserved KV key holding the snapshot in the enclave
23
+ length: number; // byte-length of the snapshotted value
24
+ fingerprint: string; // sha256 prefix of the snapshotted value
25
+ }
26
+
27
+ export function defaultVersionsPath(): string {
28
+ return process.env.BLINDFOLD_VERSIONS_LOG ?? path.join(stateDir(), "versions.jsonl");
29
+ }
30
+
31
+ /** Build the reserved enclave key for a new snapshot of `name`. */
32
+ export function versionKeyFor(name: string, stampMs: number): string {
33
+ return `__bfver__${name}__${stampMs}`;
34
+ }
35
+
36
+ /**
37
+ * Guard against a tampered versions.jsonl pointing `rollback` at an arbitrary
38
+ * enclave key: a legitimate versionKey is always `__bfver__<name>__<digits>`.
39
+ * (rollback additionally verifies the released value's fingerprint.)
40
+ */
41
+ export function isValidVersionKey(name: string, versionKey: string): boolean {
42
+ return new RegExp(`^__bfver__${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}__\\d+$`).test(versionKey);
43
+ }
44
+
45
+ export function recordVersion(entry: VersionEntry): void {
46
+ const p = defaultVersionsPath();
47
+ try {
48
+ fs.mkdirSync(path.dirname(p), { recursive: true });
49
+ fs.appendFileSync(p, JSON.stringify(entry) + "\n");
50
+ } catch {
51
+ /* never let version bookkeeping crash a rotate */
52
+ }
53
+ }
54
+
55
+ /** All version entries (optionally for one name), oldest-first. */
56
+ export function readVersions(name?: string): VersionEntry[] {
57
+ const p = defaultVersionsPath();
58
+ if (!fs.existsSync(p)) return [];
59
+ const all = fs.readFileSync(p, "utf8")
60
+ .split("\n").filter(Boolean)
61
+ .map(l => { try { return JSON.parse(l) as VersionEntry; } catch { return null; } })
62
+ .filter((e): e is VersionEntry => e !== null);
63
+ return name ? all.filter(e => e.name === name) : all;
64
+ }
package/src/wrap.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Optional in-process integration:
3
+ *
4
+ * import OpenAI from "openai";
5
+ * import { wrap } from "blindfold";
6
+ * const openai = wrap(new OpenAI({ apiKey: "__blindfold__" }));
7
+ *
8
+ * `wrap` replaces the OpenAI SDK's `fetch` with one that points at the
9
+ * local Blindfold proxy. The "apiKey" passed to OpenAI is meaningless;
10
+ * the SDK requires *some* string, so we use the sentinel.
11
+ *
12
+ * The wrap layer never sees the real key. Substitution happens inside
13
+ * the T3 contract, downstream.
14
+ */
15
+ import http from "node:http";
16
+ import { DEFAULT_PORT, SENTINEL } from "./constants.ts";
17
+
18
+ export interface WrapOpts {
19
+ /** Defaults to http://127.0.0.1:<DEFAULT_PORT>/v1 */
20
+ baseUrl?: string;
21
+ /**
22
+ * Per-session proxy auth token. When set, the wrapped client sends it on
23
+ * every request as the `x-blindfold-token` header, so the proxy accepts calls
24
+ * only from this wrapped client (not any co-resident local process). Falls
25
+ * back to the `BLINDFOLD_PROXY_TOKEN` env var.
26
+ */
27
+ token?: string;
28
+ /**
29
+ * Route requests over a unix-domain socket (see `blindfold proxy --socket`)
30
+ * instead of a TCP host:port. The request URL's host is ignored; only its
31
+ * path is used. Falls back to the `BLINDFOLD_PROXY_SOCKET` env var.
32
+ */
33
+ socket?: string;
34
+ }
35
+
36
+ const PROXY_TOKEN_HEADER = "x-blindfold-token";
37
+
38
+ /**
39
+ * A `fetch`-compatible function that sends the request over a unix-domain
40
+ * socket via node:http (`socketPath`). Only the URL's path+query is used; the
41
+ * host is irrelevant when talking to a socket. Optionally injects the
42
+ * per-session token header.
43
+ */
44
+ function socketFetch(socketPath: string, token?: string): typeof fetch {
45
+ return (async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1] = {}) => {
46
+ const reqObj = typeof Request !== "undefined" && input instanceof Request ? input : undefined;
47
+ const rawUrl = reqObj ? reqObj.url : String(input);
48
+ const url = new URL(rawUrl);
49
+ const method = init?.method ?? reqObj?.method ?? "GET";
50
+ const headers = new Headers(init?.headers ?? reqObj?.headers);
51
+ if (token) headers.set(PROXY_TOKEN_HEADER, token);
52
+
53
+ const outHeaders: Record<string, string> = {};
54
+ headers.forEach((v, k) => { outHeaders[k] = v; });
55
+
56
+ return await new Promise<Response>((resolve, reject) => {
57
+ const req = http.request(
58
+ { socketPath, path: url.pathname + url.search, method, headers: outHeaders },
59
+ (res) => {
60
+ const chunks: Buffer[] = [];
61
+ res.on("data", (c) => chunks.push(c as Buffer));
62
+ res.on("end", () => {
63
+ const respHeaders = new Headers();
64
+ for (const [k, v] of Object.entries(res.headers)) {
65
+ if (typeof v === "string") respHeaders.set(k, v);
66
+ else if (Array.isArray(v)) respHeaders.set(k, v.join(", "));
67
+ }
68
+ resolve(new Response(Buffer.concat(chunks), {
69
+ status: res.statusCode ?? 502,
70
+ statusText: res.statusMessage ?? "",
71
+ headers: respHeaders,
72
+ }));
73
+ });
74
+ },
75
+ );
76
+ req.on("error", reject);
77
+ const body = init?.body;
78
+ if (body != null) {
79
+ if (typeof body === "string" || body instanceof Buffer || body instanceof Uint8Array) req.write(body);
80
+ else if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) req.write(body.toString());
81
+ else if (ArrayBuffer.isView(body as ArrayBufferView)) { const v = body as ArrayBufferView; req.write(Buffer.from(v.buffer, v.byteOffset, v.byteLength)); }
82
+ else if (body instanceof ArrayBuffer) req.write(Buffer.from(body));
83
+ else { req.destroy(); reject(new Error("socketFetch: unsupported body type (use string/Buffer/typed-array/URLSearchParams)")); return; }
84
+ }
85
+ req.end();
86
+ });
87
+ }) as typeof fetch;
88
+ }
89
+
90
+ /**
91
+ * Loose structural type — we don't want a hard dep on the openai package.
92
+ * Anything with `baseURL` and `fetch` (or `apiKey`) fields can be wrapped.
93
+ */
94
+ export interface OpenAIish {
95
+ baseURL?: string;
96
+ apiKey?: string;
97
+ fetch?: typeof fetch;
98
+ }
99
+
100
+ export function wrap<T extends OpenAIish>(client: T, opts: WrapOpts = {}): T {
101
+ const baseUrl = opts.baseUrl ?? `http://127.0.0.1:${DEFAULT_PORT}/v1`;
102
+ client.baseURL = baseUrl;
103
+ client.apiKey = SENTINEL;
104
+
105
+ const token = (opts.token ?? process.env.BLINDFOLD_PROXY_TOKEN ?? "").trim() || undefined;
106
+ const socket = (opts.socket ?? process.env.BLINDFOLD_PROXY_SOCKET ?? "").trim() || undefined;
107
+
108
+ if (socket) {
109
+ // Talk to the proxy over its unix-domain socket (adds the token too, if set).
110
+ client.fetch = socketFetch(socket, token);
111
+ } else if (token) {
112
+ // Inject the per-session token on every outbound request without a hard
113
+ // dep on the OpenAI SDK: wrap whatever fetch the client uses.
114
+ const base = client.fetch ?? fetch;
115
+ client.fetch = ((input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1] = {}) => {
116
+ const headers = new Headers(init?.headers);
117
+ headers.set(PROXY_TOKEN_HEADER, token);
118
+ return base(input, { ...init, headers });
119
+ }) as typeof fetch;
120
+ }
121
+ return client;
122
+ }