@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,155 @@
1
+ /**
2
+ * OS keychain storage for the tenant key (T3N_API_KEY).
3
+ *
4
+ * v0.2 stored the tenant key in ~/.blindfold/config.json (0600). That's a
5
+ * plaintext file a prompt-injected agent could read — the residual risk. v0.3+
6
+ * moves it into the OS credential store so it isn't a readable file at all:
7
+ * - macOS → `security` (Keychain)
8
+ * - Linux → `secret-tool` (libsecret / GNOME Keyring)
9
+ * - Windows → PowerShell + Win32 Credential Manager (advapi32 Cred*)
10
+ * - other → not available; callers fall back to the 0600 file.
11
+ *
12
+ * Dependency-free by design (shells out to the platform tool), matching the
13
+ * rest of Blindfold. The secret is keyed by tenant DID so multiple tenants can
14
+ * coexist. Values are never logged, and are passed to child processes via env
15
+ * or stdin (never argv) wherever the platform tool allows.
16
+ */
17
+ import { spawnSync } from "node:child_process";
18
+
19
+ const SERVICE = "blindfold";
20
+ const isWin = process.platform === "win32";
21
+
22
+ function has(cmd: string): boolean {
23
+ const finder = isWin ? "where" : "which";
24
+ const r = spawnSync(finder, [cmd], { stdio: "ignore" });
25
+ return r.status === 0;
26
+ }
27
+
28
+ /* -------------------- Windows Credential Manager (advapi32) --------------- */
29
+ // A tiny C# shim compiled by Add-Type, calling CredWriteW/CredReadW/CredDeleteW.
30
+ // The secret + target are passed via environment (BF_SECRET/BF_TARGET), never
31
+ // on the command line.
32
+ const WIN_CS = `
33
+ using System;
34
+ using System.Runtime.InteropServices;
35
+ public static class BFCred {
36
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
37
+ public struct CREDENTIAL {
38
+ public UInt32 Flags; public UInt32 Type;
39
+ [MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
40
+ [MarshalAs(UnmanagedType.LPWStr)] public string Comment;
41
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
42
+ public UInt32 CredentialBlobSize; public IntPtr CredentialBlob; public UInt32 Persist;
43
+ public UInt32 AttributeCount; public IntPtr Attributes;
44
+ [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias;
45
+ [MarshalAs(UnmanagedType.LPWStr)] public string UserName;
46
+ }
47
+ [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] public static extern bool CredWriteW(ref CREDENTIAL c, UInt32 f);
48
+ [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] public static extern bool CredReadW(string t, UInt32 ty, UInt32 f, out IntPtr c);
49
+ [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] public static extern bool CredDeleteW(string t, UInt32 ty, UInt32 f);
50
+ [DllImport("advapi32.dll")] public static extern void CredFree(IntPtr c);
51
+ }
52
+ `;
53
+
54
+ const PS_HEADER = `$ErrorActionPreference='Stop'\nAdd-Type -TypeDefinition @"\n${WIN_CS}\n"@\n`;
55
+
56
+ function winPS(op: "set" | "get" | "delete", target: string, secret?: string): { status: number | null; stdout: string } {
57
+ let body: string;
58
+ if (op === "set") {
59
+ body =
60
+ `$b=[System.Text.Encoding]::UTF8.GetBytes($env:BF_SECRET);` +
61
+ `$p=[Runtime.InteropServices.Marshal]::AllocHGlobal($b.Length);` +
62
+ `[Runtime.InteropServices.Marshal]::Copy($b,0,$p,$b.Length);` +
63
+ `$c=New-Object BFCred+CREDENTIAL;$c.Type=1;$c.TargetName=$env:BF_TARGET;$c.UserName=$env:BF_TARGET;` +
64
+ `$c.CredentialBlobSize=$b.Length;$c.CredentialBlob=$p;$c.Persist=2;` +
65
+ `$ok=[BFCred]::CredWriteW([ref]$c,0);[Runtime.InteropServices.Marshal]::FreeHGlobal($p);if($ok){[Console]::Out.Write('BFOK')}`;
66
+ } else if (op === "get") {
67
+ body =
68
+ `$ptr=[IntPtr]::Zero;` +
69
+ `if([BFCred]::CredReadW($env:BF_TARGET,1,0,[ref]$ptr)){` +
70
+ `$c=[Runtime.InteropServices.Marshal]::PtrToStructure($ptr,[BFCred+CREDENTIAL]);` +
71
+ `$n=[int]$c.CredentialBlobSize;$b=New-Object byte[] $n;` +
72
+ `[Runtime.InteropServices.Marshal]::Copy([IntPtr]$c.CredentialBlob,$b,0,$n);[BFCred]::CredFree($ptr);` +
73
+ `[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($b))}else{exit 1}`;
74
+ } else {
75
+ body = `if([BFCred]::CredDeleteW($env:BF_TARGET,1,0)){[Console]::Out.Write('BFOK')}`;
76
+ }
77
+ const env: Record<string, string> = { ...(process.env as Record<string, string>), BF_TARGET: target };
78
+ if (secret !== undefined) env.BF_SECRET = secret;
79
+ const r = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"],
80
+ { input: PS_HEADER + body, env, encoding: "utf8" });
81
+ return { status: r.status, stdout: typeof r.stdout === "string" ? r.stdout : "" };
82
+ }
83
+
84
+ function winTarget(account: string): string {
85
+ return `${SERVICE}:${account}`;
86
+ }
87
+
88
+ /* ------------------------------------------------------------------------- */
89
+
90
+ /** True if an OS credential store is usable on this machine. */
91
+ export function keychainAvailable(): boolean {
92
+ if (process.platform === "darwin") return has("security");
93
+ if (process.platform === "linux") return has("secret-tool");
94
+ if (isWin) return has("powershell");
95
+ return false;
96
+ }
97
+
98
+ /** Human-readable name of the active backend (for `whoami`). */
99
+ export function keychainBackend(): string {
100
+ if (process.platform === "darwin") return "macOS Keychain";
101
+ if (process.platform === "linux") return "libsecret (secret-tool)";
102
+ if (isWin) return "Windows Credential Manager";
103
+ return "none";
104
+ }
105
+
106
+ /** Store `secret` under the tenant `account` (DID). Returns true on success. */
107
+ export function keychainSet(account: string, secret: string): boolean {
108
+ if (process.platform === "darwin") {
109
+ const r = spawnSync("security", ["add-generic-password", "-a", account, "-s", SERVICE, "-w", secret, "-U"], { stdio: "ignore" });
110
+ return r.status === 0;
111
+ }
112
+ if (process.platform === "linux") {
113
+ const r = spawnSync("secret-tool", ["store", "--label=blindfold", "service", SERVICE, "account", account], { input: secret, stdio: ["pipe", "ignore", "ignore"] });
114
+ return r.status === 0;
115
+ }
116
+ // Status codes over piped PowerShell are unreliable; use an explicit stdout
117
+ // marker. (Fails with err 1312 in a non-interactive session — e.g. over SSH —
118
+ // where the Credential Manager isn't available; caller falls back to a file.)
119
+ if (isWin) return winPS("set", winTarget(account), secret).stdout.includes("BFOK");
120
+ return false;
121
+ }
122
+
123
+ /** Retrieve the secret for tenant `account` (DID), or null if absent. */
124
+ export function keychainGet(account: string): string | null {
125
+ if (process.platform === "darwin") {
126
+ const r = spawnSync("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w"], { encoding: "utf8" });
127
+ if (r.status === 0 && typeof r.stdout === "string") return r.stdout.replace(/\n$/, "");
128
+ return null;
129
+ }
130
+ if (process.platform === "linux") {
131
+ const r = spawnSync("secret-tool", ["lookup", "service", SERVICE, "account", account], { encoding: "utf8" });
132
+ if (r.status === 0 && r.stdout) return r.stdout.replace(/\n$/, "");
133
+ return null;
134
+ }
135
+ if (isWin) {
136
+ const r = winPS("get", winTarget(account));
137
+ if (r.status === 0 && r.stdout) return r.stdout.replace(/\r?\n$/, "");
138
+ return null;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /** Remove the stored secret for tenant `account` (DID). */
144
+ export function keychainDelete(account: string): boolean {
145
+ if (process.platform === "darwin") {
146
+ const r = spawnSync("security", ["delete-generic-password", "-a", account, "-s", SERVICE], { stdio: "ignore" });
147
+ return r.status === 0;
148
+ }
149
+ if (process.platform === "linux") {
150
+ const r = spawnSync("secret-tool", ["clear", "service", SERVICE, "account", account], { stdio: "ignore" });
151
+ return r.status === 0;
152
+ }
153
+ if (isWin) return winPS("delete", winTarget(account)).stdout.includes("BFOK");
154
+ return false;
155
+ }
package/src/log.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Logging utilities that never leak secret values.
3
+ *
4
+ * Rule: `safeLog` is the ONLY logger used across Blindfold. It scrubs
5
+ * any header named authorization-ish before printing. CI greps for any
6
+ * `console.log` containing the substring "Bearer " — that should never
7
+ * appear in source.
8
+ */
9
+
10
+ const HEADER_BLOCKLIST = new Set([
11
+ "authorization",
12
+ "proxy-authorization",
13
+ "x-api-key",
14
+ "cookie",
15
+ "set-cookie",
16
+ ]);
17
+
18
+ export type Loggable = Record<string, unknown> | string;
19
+
20
+ export function safeLog(level: "info" | "warn" | "error", obj: Loggable): void {
21
+ const safe = typeof obj === "string" ? { msg: obj } : redact(obj);
22
+ const line = JSON.stringify({ t: new Date().toISOString(), level, ...safe });
23
+ process.stderr.write(line + "\n");
24
+ }
25
+
26
+ export function redact(input: Record<string, unknown>): Record<string, unknown> {
27
+ const out: Record<string, unknown> = {};
28
+ for (const [k, v] of Object.entries(input)) {
29
+ if (k === "headers" && v && typeof v === "object") {
30
+ out[k] = redactHeaders(v as Record<string, unknown> | Array<[string, string]>);
31
+ } else if (HEADER_BLOCKLIST.has(k.toLowerCase())) {
32
+ out[k] = "[redacted]";
33
+ } else {
34
+ out[k] = v;
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function redactHeaders(
41
+ h: Record<string, unknown> | Array<[string, string]>,
42
+ ): Record<string, string> | Array<[string, string]> {
43
+ if (Array.isArray(h)) {
44
+ return h.map(([k, v]): [string, string] => [k, HEADER_BLOCKLIST.has(k.toLowerCase()) ? "[redacted]" : v]);
45
+ }
46
+ const out: Record<string, string> = {};
47
+ for (const [k, v] of Object.entries(h)) {
48
+ out[k] = HEADER_BLOCKLIST.has(k.toLowerCase()) ? "[redacted]" : String(v);
49
+ }
50
+ return out;
51
+ }
package/src/migrate.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `blindfold migrate` — one command to move a whole .env into the enclave.
3
+ *
4
+ * Scans .env, seals every recognized SECRET (skipping config + T3 root creds),
5
+ * then rewrites .env to remove (or comment out) the sealed lines — with a backup.
6
+ * After this, the agent's environment holds zero plaintext API keys.
7
+ *
8
+ * SECURITY: this module never logs a secret value. It only reads names from
9
+ * .env and delegates the single plaintext touch to registerSecret (the one
10
+ * audit-critical path). The .env rewrite operates on line text, not values.
11
+ */
12
+ import fs from "node:fs";
13
+ import { defaultEnvPath, loadEnvFromFile } from "./env.ts";
14
+ import { registerSecret } from "./register.ts";
15
+
16
+ /** Vars that must NEVER be sealed (root creds / config the runtime needs). */
17
+ const NEVER_SEAL = new Set([
18
+ "T3N_API_KEY", "DID",
19
+ "BLINDFOLD_MOCK", "BLINDFOLD_PORT", "BLINDFOLD_T3_ENV", "BLINDFOLD_DASHBOARD_PORT", "BLINDFOLD_BASE_URL",
20
+ ]);
21
+
22
+ /** Looks like config, not a secret (host/url/port/email/region/env). */
23
+ function isConfigName(k: string): boolean {
24
+ return /(_HOST|_URL|_PORT|_EMAIL|_ENV|_REGION|_BASE_URL|_USER|_USERNAME)$/i.test(k)
25
+ || /^(NODE_ENV|PORT|HOST|PATH|HOME|USER|SHELL|LANG|PWD)$/i.test(k);
26
+ }
27
+
28
+ /** Alternate T3 team keys/DIDs (t1_*, t2_*, …) — these are root creds, skip. */
29
+ function isAltT3(k: string): boolean {
30
+ return /^t\d+_/i.test(k) || /_DID$/i.test(k);
31
+ }
32
+
33
+ /** Name indicates a credential worth sealing. */
34
+ function looksSecret(k: string): boolean {
35
+ return /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|_API|API_|_PAT|CREDENTIAL|ACCESS|PRIVATE)/i.test(k);
36
+ }
37
+
38
+ export interface MigratePlanItem {
39
+ envVar: string;
40
+ sealName: string;
41
+ bytes: number;
42
+ action: "seal" | "skip";
43
+ reason?: string;
44
+ }
45
+
46
+ /** Parse .env into ordered [key, rawLineIndex] without exposing values. */
47
+ function readEnvVars(envPath: string): { keys: string[]; lines: string[] } {
48
+ const lines = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8").split(/\r?\n/) : [];
49
+ const keys: string[] = [];
50
+ for (const raw of lines) {
51
+ const line = raw.trim();
52
+ if (!line || line.startsWith("#")) continue;
53
+ const eq = line.indexOf("=");
54
+ if (eq <= 0) continue;
55
+ keys.push(line.slice(0, eq).trim());
56
+ }
57
+ return { keys, lines };
58
+ }
59
+
60
+ /** Build the migration plan: which vars get sealed, which are skipped and why. */
61
+ export function planMigration(envPath = defaultEnvPath()): MigratePlanItem[] {
62
+ loadEnvFromFile(envPath); // populate process.env so we can read lengths
63
+ const { keys } = readEnvVars(envPath);
64
+ const seen = new Set<string>();
65
+ const plan: MigratePlanItem[] = [];
66
+ for (const k of keys) {
67
+ if (seen.has(k)) continue;
68
+ seen.add(k);
69
+ const val = process.env[k] ?? "";
70
+ const base: MigratePlanItem = { envVar: k, sealName: k.toLowerCase(), bytes: val.length, action: "skip" };
71
+ if (NEVER_SEAL.has(k)) plan.push({ ...base, reason: "root cred / config — must stay in .env" });
72
+ else if (isAltT3(k)) plan.push({ ...base, reason: "T3 team key/DID — root cred" });
73
+ else if (isConfigName(k)) plan.push({ ...base, reason: "config, not a secret" });
74
+ else if (!val) plan.push({ ...base, reason: "empty" });
75
+ else if (!looksSecret(k)) plan.push({ ...base, reason: "name doesn't look like a secret (seal manually if it is)" });
76
+ else plan.push({ ...base, action: "seal" });
77
+ }
78
+ return plan;
79
+ }
80
+
81
+ export interface MigrateResult extends MigratePlanItem {
82
+ sealed?: boolean;
83
+ error?: string;
84
+ }
85
+
86
+ /**
87
+ * Execute the plan: seal each "seal" item, then rewrite .env.
88
+ * @param opts.keep comment the line out (prefix `# sealed→enclave: `) instead of deleting it.
89
+ */
90
+ export async function runMigrate(opts: { envPath?: string; keep?: boolean } = {}): Promise<MigrateResult[]> {
91
+ const envPath = opts.envPath ?? defaultEnvPath();
92
+ const plan = planMigration(envPath);
93
+ const results: MigrateResult[] = [];
94
+ const sealedVars = new Set<string>();
95
+
96
+ for (const item of plan) {
97
+ if (item.action !== "seal") { results.push(item); continue; }
98
+ try {
99
+ await registerSecret({ name: item.sealName, fromEnv: item.envVar });
100
+ sealedVars.add(item.envVar);
101
+ results.push({ ...item, sealed: true });
102
+ } catch (e) {
103
+ results.push({ ...item, sealed: false, error: (e as Error).message });
104
+ }
105
+ }
106
+
107
+ // Rewrite .env (backup first) — drop/comment the successfully-sealed lines.
108
+ if (sealedVars.size > 0) {
109
+ const original = fs.readFileSync(envPath, "utf8");
110
+ // The backup contains ALL original plaintext secrets. Write it 0600 (owner
111
+ // only) so it isn't world/group readable, and warn the user to delete it
112
+ // once they've verified the migration.
113
+ const backupPath = `${envPath}.bak.${Math.floor(Date.now() / 1000)}`;
114
+ fs.writeFileSync(backupPath, original, { mode: 0o600 });
115
+ try { fs.chmodSync(backupPath, 0o600); } catch { /* best effort (umask/FS) */ }
116
+ console.warn(
117
+ `⚠️ Wrote a PLAINTEXT backup of your secrets to ${backupPath} (mode 0600). ` +
118
+ `Delete it once you've verified the migration: rm "${backupPath}"`,
119
+ );
120
+ const out: string[] = [];
121
+ for (const raw of original.split(/\r?\n/)) {
122
+ const eq = raw.indexOf("=");
123
+ const key = eq > 0 ? raw.slice(0, eq).trim() : "";
124
+ if (key && sealedVars.has(key) && !raw.trim().startsWith("#")) {
125
+ if (opts.keep) out.push(`# sealed→enclave: ${key} (value lives in T3, use \`blindfold use --name ${key.toLowerCase()}\`)`);
126
+ // else: drop the line entirely
127
+ } else {
128
+ out.push(raw);
129
+ }
130
+ }
131
+ fs.writeFileSync(envPath, out.join("\n"));
132
+ }
133
+
134
+ return results;
135
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Tiny stdlib-only helper for reading a secret from stdin with no echo.
3
+ * Like `npm login` / `gh auth login --with-token` — the value passes
4
+ * terminal → SDK → enclave and never touches the filesystem or shell
5
+ * history.
6
+ *
7
+ * Behaviour:
8
+ * - prompts via stderr (so stdout stays clean for scripting)
9
+ * - puts the TTY into raw mode and reads char-by-char
10
+ * - handles Enter (commit), Backspace, Ctrl+C (abort)
11
+ * - never prints the typed characters
12
+ *
13
+ * If stdin isn't a TTY (e.g. CI), we read a line normally without raw
14
+ * mode so piping still works (`echo $KEY | blindfold register …`).
15
+ */
16
+
17
+ export async function readSecretLine(prompt: string): Promise<string> {
18
+ process.stderr.write(prompt);
19
+ const stdin = process.stdin;
20
+
21
+ // Non-TTY path: just read one line. Useful for piped input.
22
+ if (!stdin.isTTY) {
23
+ return await readOneLine(stdin);
24
+ }
25
+
26
+ return await new Promise<string>((resolve, reject) => {
27
+ let buf = "";
28
+ stdin.setRawMode(true);
29
+ stdin.resume();
30
+ stdin.setEncoding("utf8");
31
+
32
+ const cleanup = (): void => {
33
+ stdin.removeListener("data", onData);
34
+ stdin.setRawMode(false);
35
+ stdin.pause();
36
+ };
37
+
38
+ const onData = (chunk: string | Buffer): void => {
39
+ const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
40
+ for (const ch of s) {
41
+ if (ch === "\n" || ch === "\r") {
42
+ cleanup();
43
+ process.stderr.write("\n");
44
+ resolve(buf);
45
+ return;
46
+ }
47
+ if (ch === "\u0003") {
48
+ // Ctrl+C
49
+ cleanup();
50
+ process.stderr.write("\n");
51
+ reject(new Error("aborted by user"));
52
+ return;
53
+ }
54
+ if (ch === "\u007f" || ch === "\b") {
55
+ if (buf.length > 0) buf = buf.slice(0, -1);
56
+ continue;
57
+ }
58
+ // Ignore other control chars
59
+ if (ch >= " " || ch === "\t") buf += ch;
60
+ }
61
+ };
62
+
63
+ stdin.on("data", onData);
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Read one visible line from stdin (echo on). For non-secret prompts like an
69
+ * emailed OTP code, where hiding the input would just confuse the user.
70
+ */
71
+ export async function readLine(prompt: string): Promise<string> {
72
+ process.stderr.write(prompt);
73
+ const stdin = process.stdin;
74
+ if (!stdin.isTTY) return await readOneLine(stdin);
75
+ return await new Promise<string>((resolve, reject) => {
76
+ let buf = "";
77
+ stdin.resume();
78
+ stdin.setEncoding("utf8");
79
+ const cleanup = (): void => {
80
+ stdin.removeListener("data", onData);
81
+ stdin.pause();
82
+ };
83
+ const onData = (chunk: string | Buffer): void => {
84
+ const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
85
+ for (const ch of s) {
86
+ if (ch === "\r" || ch === "\n") {
87
+ process.stderr.write("\n");
88
+ cleanup();
89
+ resolve(buf);
90
+ return;
91
+ }
92
+ if (ch === "") { // Ctrl+C
93
+ cleanup();
94
+ reject(new Error("aborted"));
95
+ return;
96
+ }
97
+ buf += ch;
98
+ }
99
+ };
100
+ stdin.on("data", onData);
101
+ });
102
+ }
103
+
104
+ function readOneLine(stdin: NodeJS.ReadStream): Promise<string> {
105
+ return new Promise((resolve, reject) => {
106
+ let buf = "";
107
+ stdin.setEncoding("utf8");
108
+ stdin.on("data", (d) => {
109
+ buf += d;
110
+ });
111
+ stdin.on("end", () => resolve(buf.replace(/\r?\n$/, "")));
112
+ stdin.on("error", reject);
113
+ });
114
+ }
@@ -0,0 +1,224 @@
1
+ import { SENTINEL } from "./constants.ts";
2
+
3
+ /**
4
+ * Blindfold provider registry — concrete, first-class integrations.
5
+ *
6
+ * This is deliberately NOT a generic passthrough. Each entry is a real,
7
+ * named provider with its exact upstream host, the logical name of the sealed
8
+ * secret it uses, and — crucially — the *auth scheme* the enclave must apply.
9
+ *
10
+ * Three schemes, matching contract/src/forward.rs:
11
+ * - bearer : `Authorization: Bearer <secret>` (sentinel swap)
12
+ * - basic : `Authorization: Basic base64(user:secret)` (computed in-enclave)
13
+ * - sigv4 : AWS Signature Version 4 (secret SIGNS the request, never sent)
14
+ *
15
+ * The basic/sigv4 providers are the proof that Blindfold is properly
16
+ * provider-aware: for those, the raw secret is *consumed by a computation*
17
+ * inside TDX, so it never exists as a standalone header value anywhere.
18
+ */
19
+
20
+ /** Auth descriptor resolved for a single outbound request. Serialises 1:1 into
21
+ * the contract's tagged `AuthSpec` enum. */
22
+ export type ForwardAuth =
23
+ | { scheme: "bearer" }
24
+ | { scheme: "basic"; username: string }
25
+ | { scheme: "sigv4"; access_key_id: string; region: string; service: string; amz_date: string }
26
+ | { scheme: "webhook" };
27
+
28
+ /** For bearer providers: which header carries the sentinel, and its prefix.
29
+ * Defaults to Authorization / "Bearer ". Google Gemini uses `x-goog-api-key`
30
+ * with no prefix — a real, distinct industry auth pattern (API key in a
31
+ * provider-specific header, not `Authorization`). */
32
+ export interface SentinelHeader {
33
+ name: string;
34
+ prefix: string;
35
+ }
36
+
37
+ export interface ResolvedProvider {
38
+ /** Provider id for telemetry, e.g. "stripe". */
39
+ id: string;
40
+ /** Absolute upstream URL to call. */
41
+ upstream: string;
42
+ /** Sealed-secret name. `undefined` → use the proxy's default secret_key
43
+ * (preserves back-compat for the OpenAI-shaped LLM providers). */
44
+ secretKey?: string;
45
+ /** How the enclave should authenticate this request. */
46
+ auth: ForwardAuth;
47
+ /** Bearer-only: header to plant the sentinel in. Defaults to Authorization. */
48
+ sentinelHeader?: SentinelHeader;
49
+ /** Provider-specific headers this API actually requires (version pins,
50
+ * Accept, User-Agent, …). Injected only when the agent didn't set them, so
51
+ * the integration knows the provider's conventions and the agent doesn't
52
+ * have to. This is what makes each entry a real integration, not a host. */
53
+ defaultHeaders?: Record<string, string>;
54
+ }
55
+
56
+ interface ProviderDef {
57
+ id: string;
58
+ /** Path prefix the agent hits on the local proxy, e.g. "/stripe/". */
59
+ prefix: string;
60
+ /** Build the real upstream URL from the incoming proxy path. */
61
+ upstream: (path: string) => string;
62
+ secretKey?: string;
63
+ /** Resolve the auth descriptor (may read non-secret config from env). */
64
+ auth: () => ForwardAuth;
65
+ /** Bearer-only override for where the sentinel goes. */
66
+ sentinelHeader?: SentinelHeader;
67
+ /** Provider-specific required headers (see ResolvedProvider.defaultHeaders). */
68
+ defaultHeaders?: Record<string, string>;
69
+ }
70
+
71
+ /** AWS-style timestamp `YYYYMMDDTHHMMSSZ` (UTC). Not a secret — the enclave has
72
+ * no wall clock, so the caller supplies it. */
73
+ export function amzDate(now: Date = new Date()): string {
74
+ const p = (n: number) => String(n).padStart(2, "0");
75
+ return (
76
+ `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}` +
77
+ `T${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}Z`
78
+ );
79
+ }
80
+
81
+ const awsRegion = () => process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1";
82
+ const awsAccessKeyId = () => process.env.AWS_ACCESS_KEY_ID || "";
83
+
84
+ /** Strip a leading `/<segment>` from the path (e.g. "/stripe/v1/x" → "/v1/x"). */
85
+ function stripPrefix(path: string, prefix: string): string {
86
+ const rest = path.slice(prefix.length - 1); // keep the leading slash
87
+ return rest.startsWith("/") ? rest : `/${rest}`;
88
+ }
89
+
90
+ const PROVIDERS: ProviderDef[] = [
91
+ // ---- LLM providers (OpenAI-shaped, bearer). Back-compatible. --------------
92
+ { id: "openai", prefix: "/v1/", upstream: (p) => `https://api.openai.com${p}`, auth: () => ({ scheme: "bearer" }) },
93
+ { id: "openai", prefix: "/openai/", upstream: (p) => `https://api.openai.com${stripPrefix(p, "/openai/")}`, auth: () => ({ scheme: "bearer" }) },
94
+ // Anthropic REQUIRES the anthropic-version header on the raw REST API.
95
+ { id: "anthropic", prefix: "/anthropic/", upstream: (p) => `https://api.anthropic.com${stripPrefix(p, "/anthropic/")}`, auth: () => ({ scheme: "bearer" }), defaultHeaders: { "anthropic-version": "2023-06-01" } },
96
+ { id: "xai", prefix: "/x/", upstream: (p) => `https://api.x.ai${stripPrefix(p, "/x/")}`, auth: () => ({ scheme: "bearer" }) },
97
+ { id: "groq", prefix: "/groq/", upstream: (p) => `https://api.groq.com/openai${stripPrefix(p, "/groq/")}`, auth: () => ({ scheme: "bearer" }) },
98
+
99
+ // ---- LLM: Google Gemini (native API — key rides in `x-goog-api-key`). ------
100
+ // Not OpenAI-shaped and NOT `Authorization: Bearer`. The sentinel is planted
101
+ // in Google's provider-specific header and swapped for the sealed key inside
102
+ // the enclave — proving Blindfold handles a provider's real auth convention,
103
+ // not just the one bearer shape.
104
+ {
105
+ id: "gemini",
106
+ prefix: "/gemini/",
107
+ upstream: (p) => `https://generativelanguage.googleapis.com${stripPrefix(p, "/gemini/")}`,
108
+ secretKey: "gemini_api_key",
109
+ auth: () => ({ scheme: "bearer" }),
110
+ sentinelHeader: { name: "x-goog-api-key", prefix: "" },
111
+ },
112
+
113
+ // ---- Payments: Stripe (bearer; pins the API version so behaviour is stable). -----
114
+ {
115
+ id: "stripe",
116
+ prefix: "/stripe/",
117
+ upstream: (p) => `https://api.stripe.com${stripPrefix(p, "/stripe/")}`,
118
+ secretKey: "stripe_secret_key",
119
+ auth: () => ({ scheme: "bearer" }),
120
+ defaultHeaders: { "stripe-version": "2024-06-20", "content-type": "application/x-www-form-urlencoded" },
121
+ },
122
+
123
+ // ---- Dev infra: GitHub. GitHub REJECTS requests with no User-Agent (403),
124
+ // and best practice is to pin the REST API version + set the Accept type.
125
+ {
126
+ id: "github",
127
+ prefix: "/github/",
128
+ upstream: (p) => `https://api.github.com${stripPrefix(p, "/github/")}`,
129
+ secretKey: "github_token",
130
+ auth: () => ({ scheme: "bearer" }),
131
+ defaultHeaders: {
132
+ accept: "application/vnd.github+json",
133
+ "x-github-api-version": "2022-11-28",
134
+ "user-agent": "blindfold",
135
+ },
136
+ },
137
+
138
+ // ---- Email: SendGrid (bearer; JSON v3 API). -------------------------------
139
+ {
140
+ id: "sendgrid",
141
+ prefix: "/sendgrid/",
142
+ upstream: (p) => `https://api.sendgrid.com${stripPrefix(p, "/sendgrid/")}`,
143
+ secretKey: "sendgrid_api_key",
144
+ auth: () => ({ scheme: "bearer" }),
145
+ defaultHeaders: { "content-type": "application/json" },
146
+ },
147
+
148
+ // ---- Comms: Slack (bearer bot token; Web API wants JSON+charset on POST). --
149
+ {
150
+ id: "slack",
151
+ prefix: "/slack/",
152
+ upstream: (p) => `https://slack.com/api${stripPrefix(p, "/slack/")}`,
153
+ secretKey: "slack_bot_token",
154
+ auth: () => ({ scheme: "bearer" }),
155
+ defaultHeaders: { "content-type": "application/json; charset=utf-8" },
156
+ },
157
+
158
+ // ---- Telephony: Twilio (HTTP Basic — base64 computed IN the enclave). -----
159
+ // Username = Account SID (not secret; also appears in the URL path). The
160
+ // sealed secret is the Auth Token. A generic proxy CANNOT do this: the
161
+ // base64 must be computed after the secret is joined, inside TDX.
162
+ {
163
+ id: "twilio",
164
+ prefix: "/twilio/",
165
+ upstream: (p) => `https://api.twilio.com${stripPrefix(p, "/twilio/")}`,
166
+ secretKey: "twilio_auth_token",
167
+ auth: () => ({ scheme: "basic", username: process.env.TWILIO_ACCOUNT_SID || "" }),
168
+ defaultHeaders: { "content-type": "application/x-www-form-urlencoded" },
169
+ },
170
+
171
+ // ---- Cloud: AWS SES (SigV4 — secret SIGNS, never transmitted). ------------
172
+ {
173
+ id: "aws-ses",
174
+ prefix: "/aws/ses/",
175
+ upstream: (p) => `https://email.${awsRegion()}.amazonaws.com${stripPrefix(p, "/aws/ses/")}`,
176
+ secretKey: "aws_secret_access_key",
177
+ auth: () => ({ scheme: "sigv4", access_key_id: awsAccessKeyId(), region: awsRegion(), service: "ses", amz_date: amzDate() }),
178
+ },
179
+
180
+ // ---- Cloud: AWS S3 (SigV4). -----------------------------------------------
181
+ {
182
+ id: "aws-s3",
183
+ prefix: "/aws/s3/",
184
+ upstream: (p) => `https://s3.${awsRegion()}.amazonaws.com${stripPrefix(p, "/aws/s3/")}`,
185
+ secretKey: "aws_secret_access_key",
186
+ auth: () => ({ scheme: "sigv4", access_key_id: awsAccessKeyId(), region: awsRegion(), service: "s3", amz_date: amzDate() }),
187
+ },
188
+
189
+ // ---- Webhook: Discord. The SECRET IS THE URL — the enclave substitutes the
190
+ // sealed webhook URL for the sentinel, so the agent POSTs to /discord
191
+ // with only a JSON body and never holds the URL. Needs egress for
192
+ // discord.com (`blindfold grant --host discord.com`).
193
+ {
194
+ id: "discord",
195
+ prefix: "/discord",
196
+ upstream: () => SENTINEL, // enclave replaces this with the sealed webhook URL
197
+ secretKey: "webhook_discord_url",
198
+ auth: () => ({ scheme: "webhook" }),
199
+ },
200
+ ];
201
+
202
+ /**
203
+ * Resolve the incoming proxy path to a concrete provider. Longest-prefix match
204
+ * so "/aws/s3/" wins over any shorter prefix. Returns null for unmapped paths.
205
+ */
206
+ export function resolveProvider(path: string): ResolvedProvider | null {
207
+ const def = PROVIDERS
208
+ .filter((d) => path.startsWith(d.prefix))
209
+ .sort((a, b) => b.prefix.length - a.prefix.length)[0];
210
+ if (!def) return null;
211
+ return {
212
+ id: def.id,
213
+ upstream: def.upstream(path),
214
+ secretKey: def.secretKey,
215
+ auth: def.auth(),
216
+ sentinelHeader: def.sentinelHeader,
217
+ defaultHeaders: def.defaultHeaders,
218
+ };
219
+ }
220
+
221
+ /** Names of the providers Blindfold ships first-class support for. */
222
+ export function supportedProviders(): string[] {
223
+ return [...new Set(PROVIDERS.map((p) => p.id))];
224
+ }