From f2db86d76a148a31cdec2922c817d45a978164c5 Mon Sep 17 00:00:00 2001 From: Vicky Kumar Date: Wed, 1 Jul 2026 21:49:46 +0530 Subject: [PATCH] fix: make grant additive + surface real enclave errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operational gotchas that cost real diagnosis time, fixed: #2 grant no longer clobbers the egress allowlist. T3 replaces allowedHosts on every agent-auth update, so grantEgress now unions new hosts with the previously-granted set (cached per tenant in .blindfold/egress-hosts.json) and sends the full list. `grant` prints the complete authorized set; `--replace` forces a reset. Verified: granting one new host keeps all prior hosts. #3 the proxy no longer hides enclave errors behind "internal proxy error". It now returns the real T3 failure as JSON — status, code, detail, request_id, and an actionable hint (egress_denied -> exact grant command; fuel_per_minute -> retry; secrets-ACL -> blindfold init; JSON-payload -> query-string params). Verified live against an ungranted host; granted-host happy path unchanged. --- integration-stack.md | 26 ++++++------ packages/blindfold/bin/blindfold.ts | 9 ++-- packages/blindfold/src/proxy.ts | 66 ++++++++++++++++++++++++++++- packages/blindfold/src/t3-client.ts | 44 +++++++++++++++++-- 4 files changed, 126 insertions(+), 19 deletions(-) diff --git a/integration-stack.md b/integration-stack.md index 257ea67..c602469 100644 --- a/integration-stack.md +++ b/integration-stack.md @@ -240,21 +240,23 @@ Publishing gotcha found here: a new contract id resets the **secrets-map read ACL** (`readers: only:[id]`), so `grantContractReads(newId)` must run after every `publish` or all forward calls fail with `cannot read map "…:secrets"`. -### Two operational gotchas (cost real diagnosis time — documented so they don't again) - -- **`blindfold grant` REPLACES the egress allowlist, it doesn't append.** - `agentAuthUpdate` sets `allowedHosts: ` for the contract, so each grant - overwrites the previous one. Granting `api.github.com` then `api.stripe.com` - separately leaves ONLY Stripe authorized; earlier hosts silently start - returning `egress_denied`. **Fix: grant every host in one call** — - `grant --host generativelanguage.googleapis.com,api.stripe.com,api.github.com`. - (A good follow-up would be to make the CLI merge with the existing allowlist.) +### Two operational gotchas (cost real diagnosis time — now fixed) + +- **`blindfold grant` used to REPLACE the egress allowlist.** ✅ **Fixed.** + T3 replaces `allowedHosts` on every agent-auth update, so `grant` now unions new + hosts with the previously-granted set (cached per tenant in + `.blindfold/egress-hosts.json`) and sends the full list — grants are additive. + `grant` prints the complete authorized set; `grant --replace` forces a reset. +- **The proxy hid real enclave errors behind `internal proxy error`.** ✅ **Fixed.** + The proxy now returns the real T3 error as JSON — status, `code`, `detail`, + `request_id`, and an actionable `hint` (e.g. egress not authorized → the exact + `blindfold grant` to run; `fuel_per_minute` → "rate limited, retry in ~60s"; + secrets-ACL → "run blindfold init"; JSON-payload → "use query-string params"). - **Testnet tenants have a per-minute compute quota (`fuel_per_minute`).** Hammering the enclave (tight reliability loops, retry storms, repeated demo runs) trips `HTTP 500 too_many_requests: quota exceeded (fuel_per_minute)`, - which surfaces to the agent as a generic `internal proxy error`. It looks like - an outage but resets within a minute — space calls out, and don't let demo - retry loops hammer a already-exhausted quota. + now surfaced clearly (see above) instead of a generic 500. It looks like an + outage but resets within a minute — space calls out. ### Honesty hardening of the demos diff --git a/packages/blindfold/bin/blindfold.ts b/packages/blindfold/bin/blindfold.ts index 8b180b1..e3770c3 100644 --- a/packages/blindfold/bin/blindfold.ts +++ b/packages/blindfold/bin/blindfold.ts @@ -297,13 +297,16 @@ async function main(): Promise { if (hosts.length === 0) { die("usage: blindfold grant --host [,...] (e.g. --host api.openai.com)"); } + const replace = !!argv.flags.replace; const env = loadBlindfoldEnv(); const { openT3Client } = await import("../src/t3-client.ts"); const client = await openT3Client(env); try { - await client.grantEgress(hosts); - console.log(`✓ Egress granted for: ${hosts.join(", ")}`); - console.log(` The blindfold-proxy contract (forward / release-to-tenant) may now call these hosts.`); + // T3 replaces the allowlist on every update, so grant is additive by + // default (merges with previously-granted hosts). Use --replace to reset. + const authorized = await client.grantEgress(hosts, { replace }); + console.log(`✓ Egress granted for: ${hosts.join(", ")}${replace ? " (replaced allowlist)" : ""}`); + console.log(` Contract is now authorized to call ALL of: ${authorized.join(", ")}`); console.log(` Run \`blindfold publish\` first if you haven't — the grant targets the published contract.`); } finally { await client.close(); diff --git a/packages/blindfold/src/proxy.ts b/packages/blindfold/src/proxy.ts index cda12c4..cc73b7a 100644 --- a/packages/blindfold/src/proxy.ts +++ b/packages/blindfold/src/proxy.ts @@ -148,7 +148,18 @@ async function handle( }); const startedAt = Date.now(); - const result = await t3.invokeForward(forwardReq); + let result; + try { + result = await t3.invokeForward(forwardReq); + } catch (e) { + // Surface the REAL enclave/host error (egress denied, rate limit, secrets + // ACL, payload parse) with an actionable hint — not a generic 500. + const { status, body } = explainForwardError(e as Error); + safeLog("error", { msg: "proxy_forward_failed", status, provider: provider.id, error: (e as Error).message }); + if (!res.headersSent) res.writeHead(status, { "content-type": "application/json" }); + res.end(body); + return; + } const latency = Date.now() - startedAt; // Record non-sensitive telemetry. Never the body, never the header values. @@ -213,3 +224,56 @@ function headersFromTuple(t: Array<[string, string]>): Record { for (const [k, v] of t) out[k] = v; return out; } + +/** + * Turn a raw T3 forward error into a real HTTP status + a JSON body that names + * the cause and how to fix it. The enclave/host errors look like: + * HTTP 400: Invalid params ({"code":"bad_request","detail":"…","request_id":"…"}) + * These are the exact failures that cost real diagnosis time when hidden behind + * a generic "internal proxy error". + */ +function explainForwardError(err: Error): { status: number; body: string } { + const msg = err?.message ?? String(err); + let status = Number(msg.match(/HTTP (\d{3})/)?.[1]) || 502; + let code = ""; + let detail = msg; + let requestId = ""; + const jsonM = msg.match(/\((\{.*\})\)\s*$/); + if (jsonM) { + try { + const o = JSON.parse(jsonM[1]) as Record; + code = o.code ?? ""; + detail = o.detail ?? detail; + requestId = o.request_id ?? ""; + } catch { + /* keep the raw message */ + } + } + + let hint = ""; + if (/egress_denied|authorised_hosts|allowlist/i.test(detail)) { + const host = detail.match(/host '([^']+)'/)?.[1]; + hint = `Egress is not authorized${host ? ` for '${host}'` : ""}. Run: blindfold grant --host ${host ?? ""} — list every host you use in ONE command (grant replaces the allowlist unless merged).`; + if (status < 400) status = 403; + } else if (/fuel_per_minute|too_many_requests|rate limit/i.test(detail)) { + 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."; + status = 429; + } else if (/cannot read map|:secrets/i.test(detail)) { + 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)."; + } else if (/parse_payload|expected value at line/i.test(detail)) { + 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."; + if (status < 400) status = 400; + } else if (/secret .* not found|not found in the secrets map/i.test(detail)) { + hint = "That sealed secret name doesn't exist. Seal it: blindfold register --name --from-env ."; + } + + const payload = { + error: "blindfold_forward_failed", + status, + code: code || undefined, + detail, + request_id: requestId || undefined, + hint: hint || undefined, + }; + return { status, body: JSON.stringify(payload, null, 2) }; +} diff --git a/packages/blindfold/src/t3-client.ts b/packages/blindfold/src/t3-client.ts index e2b4df3..6264f65 100644 --- a/packages/blindfold/src/t3-client.ts +++ b/packages/blindfold/src/t3-client.ts @@ -12,11 +12,43 @@ * installed it. REAL mode requires `@terminal3/t3n-sdk` (optionalDep). */ import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; import type { BlindfoldEnv, ForwardRequest, ForwardResponse } from "./types.ts"; import { CONTRACT_TAIL, CONTRACT_VERSION } from "./constants.ts"; import { assertRealReady } from "./env.ts"; import { safeLog } from "./log.ts"; +/* ---- egress allowlist cache ------------------------------------------------- + * T3 REPLACES the contract's egress allowlist on every agent-auth update, so a + * grant must send the FULL desired set each time. We remember previously-granted + * hosts per tenant in .blindfold/egress-hosts.json and union new hosts in, so + * `grant` becomes additive instead of clobbering earlier grants. + * ---------------------------------------------------------------------------- */ +function egressCachePath(): string { + return process.env.BLINDFOLD_EGRESS_CACHE ?? path.join(process.cwd(), ".blindfold", "egress-hosts.json"); +} +function loadEgressHosts(did: string): string[] { + try { + const all = JSON.parse(fs.readFileSync(egressCachePath(), "utf8")) as Record; + return Array.isArray(all[did]) ? all[did] : []; + } catch { + return []; + } +} +function saveEgressHosts(did: string, hosts: string[]): void { + const p = egressCachePath(); + let all: Record = {}; + try { + all = JSON.parse(fs.readFileSync(p, "utf8")) as Record; + } catch { + /* fresh file */ + } + all[did] = hosts; + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(all, null, 2)); +} + /** Loaded SDK module shape (subset of the real @terminal3/t3n-sdk exports). */ interface T3Sdk { setEnvironment: (env: "testnet" | "production") => void; @@ -78,7 +110,7 @@ export interface T3ClientHandle { * functions to make outbound calls to the given hosts (the egress grant the * proxy + in-enclave http::call path require). */ - grantEgress: (hosts: string[]) => Promise; + grantEgress: (hosts: string[], opts?: { replace?: boolean }) => Promise; /** * Authorize an arbitrary agent DID (a teammate) to call this tenant's * contract functions for the given hosts. Empty hosts+functions revokes. @@ -260,8 +292,13 @@ async function openRealClient(env: BlindfoldEnv): Promise { }); }; - const grantEgress = (hosts: string[]): Promise => - agentAuthUpdate(env.did, hosts, ["forward", "release-to-tenant"]); + const grantEgress = async (hosts: string[], opts?: { replace?: boolean }): Promise => { + const prev = opts?.replace ? [] : loadEgressHosts(env.did); + const merged = Array.from(new Set([...prev, ...hosts])).sort(); + await agentAuthUpdate(env.did, merged, ["forward", "release-to-tenant"]); + saveEgressHosts(env.did, merged); + return merged; + }; const setAgentGrant = (agentDid: string, hosts: string[], functions: string[]): Promise => agentAuthUpdate(agentDid, hosts, functions); @@ -325,6 +362,7 @@ function openMockClient(): T3ClientHandle { }, async grantEgress(hosts) { safeLog("info", { msg: "mock-grant-egress", hosts }); + return hosts; }, async setAgentGrant(agentDid, hosts, functions) { safeLog("info", { msg: "mock-set-agent-grant", agentDid, hosts, functions });