From f4ee00dd0705c14b228e7e49d411b2ff093f1608 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:11:04 -0700 Subject: [PATCH 1/4] Bound hosted tokens and imports and minimize diagnostic data --- apps/cloud/SECURITY-OPERATIONS.md | 67 +++++++++++++++++++ apps/cloud/src/auth/access-token-options.ts | 10 +-- apps/cloud/src/auth/errors.ts | 2 +- apps/cloud/src/auth/handlers.ts | 10 +-- apps/cloud/src/auth/workos-events-runner.ts | 2 +- apps/cloud/src/engine/execution-gate.ts | 2 +- apps/cloud/src/engine/execution-rate-limit.ts | 3 +- .../src/extensions/billing/member-seats.ts | 3 +- apps/cloud/src/extensions/billing/route.ts | 6 +- apps/cloud/src/extensions/billing/service.ts | 4 +- apps/cloud/src/observability/error-logging.ts | 14 +--- apps/cloud/src/observability/index.ts | 23 ++----- .../src/observability/redact-span-urls.ts | 7 +- .../cloud/src/observability/sentry-privacy.ts | 58 ++++++++++++++++ apps/cloud/src/request-limits.ts | 37 ++++++++++ apps/cloud/src/server.ts | 5 +- apps/cloud/wrangler.jsonc | 1 + packages/core/sdk/src/executor.ts | 31 ++------- packages/core/sdk/src/oauth-service.ts | 8 ++- packages/core/sdk/src/subject-registry.ts | 2 +- packages/hosts/mcp/src/tool-server.ts | 25 ++++--- packages/plugins/graphql/src/sdk/errors.ts | 1 + .../plugins/graphql/src/sdk/introspect.ts | 42 ++++++++++-- packages/plugins/openapi/src/sdk/parse.ts | 31 +++++++-- 24 files changed, 288 insertions(+), 106 deletions(-) create mode 100644 apps/cloud/SECURITY-OPERATIONS.md create mode 100644 apps/cloud/src/observability/sentry-privacy.ts create mode 100644 apps/cloud/src/request-limits.ts diff --git a/apps/cloud/SECURITY-OPERATIONS.md b/apps/cloud/SECURITY-OPERATIONS.md new file mode 100644 index 0000000000..7f9bb7f587 --- /dev/null +++ b/apps/cloud/SECURITY-OPERATIONS.md @@ -0,0 +1,67 @@ +# Credential access and review + +This runbook defines the review process for the hosted application. A policy +is not evidence that a review took place. Keep dated review records outside +the public repository, with the scope, findings, owner, and follow-up actions. + +## Access + +- Store connected-service credentials in WorkOS Vault. Resolve credentials + only after tenant and subject authorization. Bind vault objects to their + owner and key context. Do not expose plaintext credentials in list or + metadata responses. +- Store deployment credentials in managed secret bindings. Give a service + only the credentials it needs. Separate production and development keys. +- Require MFA for human access to production infrastructure. Application + administration requires a fresh, session-bound second-factor verification. +- Use personal identities for human administration. Review provider team + membership and service credentials when access changes. Revoke access when + a person or service no longer needs it. +- Do not log passwords, tokens, cookies, request bodies, payment details, or + raw provider errors. Keep operation names, status codes, safe error classes, + source positions, and trace identifiers for diagnosis. + +## Machine keys + +Machine API keys support unattended integrations. They are distinct from +interactive sessions. A personal key acts as its owning member and cannot +satisfy an administrator verification. Organization keys cannot satisfy the +interactive administrator gate. Evaluate tenant and subject authorization on +requests; validate key validity with WorkOS. + +For rotation, create a replacement key, update the authorized workload, verify +its requests, then revoke the old key. Revoke immediately after suspected +exposure. Do not revoke active customer keys merely to demonstrate this +procedure. Use an isolated test identity for revocation tests. + +## Review procedure + +The service owner performs the following review weekly and after an access or +credential incident: + +1. Review WorkOS Vault access events for unexpected actors, unusual volumes, + or access outside the affected workload. Inspect metadata, not secret values. +2. Compare provider team access and service credentials with current needs. +3. Review authentication and billing failures in Cloudflare and Sentry. Use + safe trace identifiers to investigate. Check that diagnostics omit secrets. +4. Verify query-string redaction after deployments and changes to telemetry. +5. Record the date, evidence range, sample size, findings, and actions in the + private security evidence folder. Distinguish a sample from a full review. + +If access is unexplained, preserve safe evidence, determine the affected +scope, revoke or rotate the affected credential, and verify that the old +credential no longer works. Follow the incident process for any customer +notification; do not publish customer or secret data in issues. + +## Evidence limits + +A visible event sample establishes that those events were recorded. It does +not establish complete coverage, a retention period, or automated alerting. +Record configured retention and alert destinations only when verified in the +provider. This runbook does not claim that automated Vault alerts exist. + +External identity providers control their own passwords. Do not claim that +changing a federated password invalidates application sessions without an +observed or documented signaling and revocation path. WorkOS documents that +its own password reset revokes active WorkOS sessions; locally verified access +tokens remain usable until their acceptance window ends. diff --git a/apps/cloud/src/auth/access-token-options.ts b/apps/cloud/src/auth/access-token-options.ts index d67915ce70..8bb4c583ec 100644 --- a/apps/cloud/src/auth/access-token-options.ts +++ b/apps/cloud/src/auth/access-token-options.ts @@ -1,10 +1,10 @@ import type { JWTVerifyOptions } from "jose"; -/** - * Require expiring WorkOS tokens. Token lifetime is controlled by WorkOS via - * `exp`; do not cap the age locally, since AuthKit issues MCP access tokens - * that live for several days. - */ +/** WorkOS credentials may authorize a request for at most 24 hours after issuance. */ +export const WORKOS_ACCESS_TOKEN_MAX_AGE_SECONDS = 24 * 60 * 60; + +/** Require signed, expiring tokens and cap their effective lifetime even if the issuer sets a later exp. */ export const workosAccessTokenOptions: JWTVerifyOptions = { requiredClaims: ["exp", "iat"], + maxTokenAge: WORKOS_ACCESS_TOKEN_MAX_AGE_SECONDS, }; diff --git a/apps/cloud/src/auth/errors.ts b/apps/cloud/src/auth/errors.ts index 6c3c8e3ac2..5720941d11 100644 --- a/apps/cloud/src/auth/errors.ts +++ b/apps/cloud/src/auth/errors.ts @@ -228,7 +228,7 @@ export const withServiceLogging = ( effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.tapCause((cause) => Effect.logError(`${name} failed`, cause)), + Effect.tapCause(() => Effect.logError(`${name} failed`)), Effect.mapError(publicError), Effect.withSpan(name), ) as Effect.Effect; diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index c62934948d..5e71add01e 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -510,7 +510,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( Effect.gen(function* () { yield* Effect.logWarning( "createOrganization: could not provision the Autumn customer", - { organizationId: org.id, error }, + { organizationId: org.id }, ); yield* captureCauseEffect(error); }), @@ -620,10 +620,10 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( { organizationId }, ), ), - Effect.tapError((error) => + Effect.tapError(() => Effect.logError( "deleteOrganization: org marked deleted but the Autumn customer could not be deleted; retry the deletion", - { organizationId, error }, + { organizationId }, ), ), Effect.mapError(() => new OrganizationDeletionIncomplete({ step: "billing" })), @@ -663,10 +663,10 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( s.deleteOrganizationCascade(organizationId, deletedAt), ) .pipe( - Effect.tapError((error) => + Effect.tapError(() => Effect.logError( "deleteOrganization: org marked deleted, removed from WorkOS and Autumn, but local purge failed, tenant data and secrets orphaned; retry the deletion", - { organizationId, error }, + { organizationId }, ), ), ); diff --git a/apps/cloud/src/auth/workos-events-runner.ts b/apps/cloud/src/auth/workos-events-runner.ts index e5979e48b4..9990681e97 100644 --- a/apps/cloud/src/auth/workos-events-runner.ts +++ b/apps/cloud/src/auth/workos-events-runner.ts @@ -103,7 +103,7 @@ export const runWorkOsEventsSync = (): Promise => Effect.scoped, Effect.catchCause((cause) => Effect.gen(function* () { - yield* Effect.logError("workos_events: sync run failed", cause); + yield* Effect.logError("workos_events: sync run failed"); yield* captureCauseEffect(cause); }), ), diff --git a/apps/cloud/src/engine/execution-gate.ts b/apps/cloud/src/engine/execution-gate.ts index 60c102663f..21d6cfd1a3 100644 --- a/apps/cloud/src/engine/execution-gate.ts +++ b/apps/cloud/src/engine/execution-gate.ts @@ -175,7 +175,7 @@ export const makeExecutionLimitGate = (checkBalance: ExecutionBalanceCheck) => { Effect.catch((error: unknown) => Effect.gen(function* () { yield* Effect.sync(() => { - console.warn("[billing] execution balance check failed open:", error); + console.warn("[billing] execution balance check failed open"); }); yield* captureCauseEffect(error); return { blocked: false } as const satisfies GateDecision; diff --git a/apps/cloud/src/engine/execution-rate-limit.ts b/apps/cloud/src/engine/execution-rate-limit.ts index 04c35f5774..3eb432e618 100644 --- a/apps/cloud/src/engine/execution-rate-limit.ts +++ b/apps/cloud/src/engine/execution-rate-limit.ts @@ -132,7 +132,7 @@ const failOpen = ( "rate_limit.check.error_tag": outcome.errorTag, }); yield* Effect.sync(() => { - console.warn("[rate-limit] execution rate limit check failed open:", error); + console.warn("[rate-limit] execution rate limit check failed open"); }); if (!outcome.timedOut) yield* captureCauseEffect(error); return { blocked: false } as const satisfies GateDecision; @@ -303,7 +303,6 @@ export const makeExecutionRateLimiter = ( `[rate-limit] exemption lookup failed for ${organizationId}; treating as ${ cached ? "last known" : "not exempt" }:`, - error, ); }); yield* captureCauseEffect(error); diff --git a/apps/cloud/src/extensions/billing/member-seats.ts b/apps/cloud/src/extensions/billing/member-seats.ts index 7ed5f2ab19..5ca8c16c23 100644 --- a/apps/cloud/src/extensions/billing/member-seats.ts +++ b/apps/cloud/src/extensions/billing/member-seats.ts @@ -64,10 +64,9 @@ export const forkReportMemberSeats = ( waitUntil(Effect.runPromise(autumn.setMemberSeats(organizationId, seats))); }); }).pipe( - Effect.catch((error) => + Effect.catch(() => Effect.logWarning("reportMemberSeats: seat recount failed", { organizationId, - error, }), ), Effect.withSpan("billing.reportMemberSeats"), diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index 8269727272..6eca015292 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -1,5 +1,5 @@ import { env } from "cloudflare:workers"; -import { Cause, Effect } from "effect"; +import { Effect } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { autumnHandler } from "autumn-js/backend"; @@ -138,7 +138,7 @@ const handler = Effect.gen(function* () { ); if (statusCode >= 400) { - console.error("[autumn] upstream error:", statusCode, response); + console.error("[autumn] upstream error", { status: statusCode }); return yield* new HttpResponseError({ status: statusCode, code: "billing_request_failed", @@ -150,7 +150,7 @@ const handler = Effect.gen(function* () { }).pipe( Effect.catchCause((err) => { if (isServerError(err)) { - console.error("[autumn] request failed:", Cause.pretty(err)); + console.error("[autumn] request failed", { status: 500 }); } return toErrorServerResponseEffect(err); }), diff --git a/apps/cloud/src/extensions/billing/service.ts b/apps/cloud/src/extensions/billing/service.ts index af04a5b829..75de6e1fb6 100644 --- a/apps/cloud/src/extensions/billing/service.ts +++ b/apps/cloud/src/extensions/billing/service.ts @@ -182,7 +182,7 @@ const make = Effect.sync(() => { // Silent billing data loss is worth paging on: autumn.trackExecution // is fire-and-forget so the caller doesn't handle it themselves. yield* Effect.sync(() => { - console.error("[billing] track failed:", error); + console.error("[billing] track failed"); }); yield* captureCauseEffect(error); yield* Effect.annotateCurrentSpan({ "autumn.track.failed": true }); @@ -217,7 +217,7 @@ const make = Effect.sync(() => { // Silent seat drift means wrong invoices, so failures page just // like a lost execution track. yield* Effect.sync(() => { - console.error("[billing] seat sync failed:", error); + console.error("[billing] seat sync failed"); }); yield* captureCauseEffect(error); yield* Effect.annotateCurrentSpan({ "autumn.members.failed": true }); diff --git a/apps/cloud/src/observability/error-logging.ts b/apps/cloud/src/observability/error-logging.ts index 008bbaf653..2583534472 100644 --- a/apps/cloud/src/observability/error-logging.ts +++ b/apps/cloud/src/observability/error-logging.ts @@ -1,17 +1,6 @@ import { Cause, Effect, Option, Predicate, Result } from "effect"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; -const MAX_LOGGED_CAUSE_CHARS = 4_000; - -const truncate = (value: string): string => - value.length <= MAX_LOGGED_CAUSE_CHARS - ? value - : `${value.slice(0, MAX_LOGGED_CAUSE_CHARS)}\n...[truncated ${ - value.length - MAX_LOGGED_CAUSE_CHARS - } chars]`; - -const loggedCause = (cause: Cause.Cause): string => truncate(Cause.pretty(cause)); - const objectValue = (value: unknown, key: string): unknown => Predicate.hasProperty(value, key) ? value[key] : undefined; @@ -65,7 +54,8 @@ export const logApiErrorCause = ( path: requestPath(request), status: httpStatus(error), errorTag: errorTag(error), - cause: loggedCause(cause), + // Error causes can contain provider responses, request bodies, and secrets. + // Keep only the classification; never serialize the exception or its stack. }); }; diff --git a/apps/cloud/src/observability/index.ts b/apps/cloud/src/observability/index.ts index 88b88419e5..f43022c76a 100644 --- a/apps/cloud/src/observability/index.ts +++ b/apps/cloud/src/observability/index.ts @@ -7,7 +7,7 @@ // `withObservability` (in @executor-js/api) wraps every handler effect; when // it sees an unmapped cause it asks `ErrorCapture.captureException` for a // trace id and fails with `InternalError({ traceId })`. The client gets -// the opaque id, we get the full cause + stack in Sentry. +// the opaque id; Sentry receives a minimized diagnostic event. // --------------------------------------------------------------------------- import * as Sentry from "@sentry/cloudflare"; @@ -15,16 +15,12 @@ import type { ErrorEvent, Scope } from "@sentry/cloudflare"; import { Cause, Effect, Layer, Predicate } from "effect"; import type * as Tracer from "effect/Tracer"; +import { minimizeSentryEvent } from "./sentry-privacy"; + import { ErrorCapture } from "@executor-js/api"; import { classifyDurableObjectError } from "@executor-js/cloudflare/mcp/durable-object-errors"; import { withStableGroupingFingerprint } from "@executor-js/sdk/sentry-grouping"; -// Drizzle/postgres-js include the failing SQL (params + bound values) in -// their error message. For OpenAPI source inserts that's 1MB+ of spec -// text which blows past terminal scrollback and hides the actual pg -// error. Sentry still receives the full, untruncated cause via -// `setExtra`; only the dev-console mirror is capped. -const MAX_CONSOLE_CAUSE_CHARS = 4_000; const OTEL_TRACE_ID_PATTERN = /^[0-9a-f]{32}$/; const OTEL_SPAN_ID_PATTERN = /^[0-9a-f]{16}$/; @@ -52,11 +48,6 @@ export type OtelCorrelationContext = { readonly spanId: string; }; -const truncate = (s: string): string => - s.length <= MAX_CONSOLE_CAUSE_CHARS - ? s - : `${s.slice(0, MAX_CONSOLE_CAUSE_CHARS)}\n…[truncated ${s.length - MAX_CONSOLE_CAUSE_CHARS} chars]`; - const validOtelContext = (context: OtelCorrelationContext): boolean => OTEL_TRACE_ID_PATTERN.test(context.traceId) && OTEL_SPAN_ID_PATTERN.test(context.spanId); @@ -212,7 +203,7 @@ export const beforeSendCloudEvent = ( options?: { readonly logPayload?: boolean }, ): ErrorEvent | null => { const reported = beforeSendWithOtelCorrelation(event, options); - return reported === null ? null : withStableGroupingFingerprint(reported); + return reported === null ? null : minimizeSentryEvent(withStableGroupingFingerprint(reported)); }; /** @@ -230,8 +221,8 @@ export const beforeSendCloudEvent = ( export const cloudSentryOptions = (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0, - enableLogs: true, - sendDefaultPii: true, + enableLogs: false, + sendDefaultPii: false, skipOpenTelemetrySetup: true, beforeSend: (event: ErrorEvent) => beforeSendCloudEvent(event, { @@ -365,7 +356,7 @@ export const ErrorCaptureLive: Layer.Layer = Layer.succeed( ErrorCapture.of({ captureException: (cause) => Effect.gen(function* () { - console.error("[api] unhandled cause:", truncate(Cause.pretty(cause))); + console.error("[api] unhandled cause", classificationTagsOf(cause)); return (yield* captureCauseEffect(cause)) ?? ""; }), }), diff --git a/apps/cloud/src/observability/redact-span-urls.ts b/apps/cloud/src/observability/redact-span-urls.ts index ced0513214..c78a966451 100644 --- a/apps/cloud/src/observability/redact-span-urls.ts +++ b/apps/cloud/src/observability/redact-span-urls.ts @@ -110,6 +110,10 @@ export class UrlRedactingSpanProcessor implements SpanProcessor { if (name !== event.name) event.name = name; if (event.attributes === undefined) continue; for (const [key, value] of Object.entries(event.attributes)) { + if (key === "exception.message" || key === "exception.stacktrace") { + event.attributes[key] = "[REDACTED]"; + continue; + } // Event attributes permit string[] exactly as span attributes do, so // array elements get the same free-text scrub, in place. if (Array.isArray(value)) { @@ -124,8 +128,7 @@ export class UrlRedactingSpanProcessor implements SpanProcessor { const message = span.status.message; if (typeof message === "string") { - const redacted = redactUrlsInText(message); - if (redacted !== message) span.status.message = redacted; + span.status.message = "[REDACTED]"; } } } diff --git a/apps/cloud/src/observability/sentry-privacy.ts b/apps/cloud/src/observability/sentry-privacy.ts new file mode 100644 index 0000000000..ff9cf7c6d6 --- /dev/null +++ b/apps/cloud/src/observability/sentry-privacy.ts @@ -0,0 +1,58 @@ +import type { ErrorEvent } from "@sentry/cloudflare"; + +const SAFE_TAGS = new Set([ + "otel_trace_id", + "otel_span_id", + "operation", + "reason", + "status", + "mcp.do.cause_owner", +]); + +const identifier = (value: string | undefined): string | undefined => + value !== undefined && /^[\w.$<>:/@ -]{1,160}$/.test(value) ? value : undefined; + +const sourceFile = (value: string | undefined): string | undefined => { + if (!value) return undefined; + const path = URL.canParse(value) ? new URL(value).pathname : value.split(/[?#]/)[0]; + return path && /^\/?(?:assets\/)?[\w./-]+\.(?:js|mjs|ts|tsx)$/.test(path) ? path : undefined; +}; + +/** Keep error classification, source positions and correlation; omit all raw payloads. */ +export const minimizeSentryEvent = (event: ErrorEvent): ErrorEvent => ({ + type: undefined, + event_id: event.event_id, + timestamp: event.timestamp, + platform: event.platform, + level: event.level, + release: event.release, + environment: event.environment, + fingerprint: event.fingerprint?.map((part) => identifier(part) ?? "Error"), + tags: Object.fromEntries( + Object.entries(event.tags ?? {}).filter( + ([key, value]) => SAFE_TAGS.has(key) && identifier(String(value)) !== undefined, + ), + ), + exception: { + values: event.exception?.values?.map((exception) => ({ + type: identifier(exception.type) ?? "Error", + value: "Details omitted to protect request data", + mechanism: exception.mechanism + ? { + type: identifier(exception.mechanism.type) ?? "generic", + handled: exception.mechanism.handled, + } + : undefined, + stacktrace: { + frames: exception.stacktrace?.frames?.map((frame) => ({ + filename: sourceFile(frame.filename), + function: identifier(frame.function), + module: identifier(frame.module), + lineno: frame.lineno, + colno: frame.colno, + in_app: frame.in_app, + })), + }, + })), + }, +}); diff --git a/apps/cloud/src/request-limits.ts b/apps/cloud/src/request-limits.ts new file mode 100644 index 0000000000..a47fd614a9 --- /dev/null +++ b/apps/cloud/src/request-limits.ts @@ -0,0 +1,37 @@ +/** Maximum accepted request body, including streamed imports and MCP calls. */ +export const MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024; + +/** Read a bounded body before dispatch, so no handler can bypass the byte limit. */ +export const limitRequestBody = async ( + request: Request, + maxBytes = MAX_REQUEST_BODY_BYTES, +): Promise => { + if (!request.body) return request; + const reject = () => Response.json({ error: "Request body too large" }, { status: 413 }); + const declaredLength = request.headers.get("content-length"); + if (declaredLength !== null && Number(declaredLength) > maxBytes) { + await request.body.cancel(); + return reject(); + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: release the native Web stream reader on every exit before entering the Effect app + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > maxBytes) { + await reader.cancel(); + return reject(); + } + chunks.push(new Uint8Array(chunk.value)); + } + } finally { + reader.releaseLock(); + } + // No clone/tee: only the bounded body reaches downstream parsers and proxies. + return new Request(request, { body: new Blob(chunks) }); +}; diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 71905e352f..3d4fd78943 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -10,6 +10,7 @@ import { import * as Sentry from "@sentry/cloudflare"; import handler from "@tanstack/react-start/server-entry"; +import { limitRequestBody } from "./request-limits"; import { isAppOwnedPath, servedByAppPlane } from "./app-paths"; import { marketingProxyRequest } from "./edge/marketing"; import { passthroughResponse } from "./edge/passthrough"; @@ -304,7 +305,9 @@ const prewarmAppPlane = (ctx: ExecutionContext): void => { }; const cloudflareHandler: ExportedHandler = { - fetch: async (request, env, ctx) => { + fetch: async (incoming, env, ctx) => { + const request = await limitRequestBody(incoming); + if (request instanceof Response) return request; isolateRequestSeq += 1; // Public pages must not enter TanStack Start: its first-request dynamic diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 4ee8b7a2b4..ac07846ebc 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -27,6 +27,7 @@ }, "observability": { "enabled": true, + "redact_query_string": true, }, "ratelimits": [ { diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index fdbc9b7671..7e6b767b06 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -911,23 +911,6 @@ const storageFailureFromUnknown = (message: string, cause: unknown): StorageFail const pluginStorageFailure = (pluginId: string, hook: string, cause: unknown): StorageFailure => storageFailureFromUnknown(`${hook} failed for plugin ${pluginId}`, cause); -// oxlint-disable executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: render an arbitrary failure into one readable log field -/** One-line rendering of a failed rebuild, for the operator-facing warning. - * A `StorageError` carries the actionable detail in its `cause` (the plugin's - * own failure) while its own message only names the hook, and structural - * stringification drops a `cause` that is an `Error` — so unwrap one level and - * keep both halves. */ -const describeSyncFailure = (error: unknown): string => { - const base = - error instanceof Error && error.message.length > 0 - ? error.message - : Inspectable.toStringUnknown(error, 0); - const cause = (error as { readonly cause?: unknown } | null | undefined)?.cause; - if (cause instanceof Error && cause.message.length > 0) return `${base}: ${cause.message}`; - return base; -}; -// oxlint-enable executor/no-instanceof-error, executor/no-unknown-error-message - const createDefaultMemoryDb = (tables: FumaTables): ExecutorDb => { const version = "1.0.0"; const latestSchema = fumaSchema>({ @@ -4228,11 +4211,11 @@ export const createExecutor = Effect.logWarning("executor stale tool sync scan failed", { - error: describeSyncFailure(error), + errorType: typeof error, }), ), ), diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index de49a7352e..0e71ae02f8 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -14,7 +14,7 @@ // redeems the session, exchanges the code, and mints the connection. // --------------------------------------------------------------------------- -import { Duration, Effect, Exit, Layer, Match, Option, Predicate, Schema } from "effect"; +import { Cause, Duration, Effect, Exit, Layer, Match, Option, Predicate, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { connectionIdentifier } from "./connection-name-identifier"; @@ -1100,7 +1100,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { { owner: input.owner, client: String(input.slug), - cause, + causeKind: Cause.isCause(cause) ? "Cause" : "Error", }, ).pipe(Effect.as(false)), ), @@ -2084,7 +2084,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ) .pipe( Effect.catch((failure) => - Effect.logWarning("executor oauth expired-session sweep failed", { cause: failure }), + Effect.logWarning("executor oauth expired-session sweep failed", { + failureType: typeof failure, + }), ), ); diff --git a/packages/core/sdk/src/subject-registry.ts b/packages/core/sdk/src/subject-registry.ts index ca02cef65a..c103c13c64 100644 --- a/packages/core/sdk/src/subject-registry.ts +++ b/packages/core/sdk/src/subject-registry.ts @@ -186,7 +186,7 @@ export const touchSubject = (db: FumaDb, input: TouchSubjectInput): Effect. Effect.logWarning("executor subject touch failed", { tenant: input.tenant, externalId: input.externalId, - cause, + failureType: typeof cause, }), ), Effect.withSpan("executor.subject.touch"), diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index f7c1a349f7..91562be746 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -395,11 +395,6 @@ const readDebugDefault = (): boolean => { return value === "1" || value === "true"; }; -const capabilitySnapshot = (server: McpServer) => ({ - clientCapabilities: server.server.getClientCapabilities() ?? null, - elicitationSupport: getElicitationSupport(server), -}); - class McpNativeElicitationTransportError extends Data.TaggedError( "McpNativeElicitationTransportError", )<{ @@ -835,10 +830,9 @@ const toMcpFailureResult = (cause: Cause.Cause): McpToolResult => { Predicate.isTagged("McpNativeElicitationTransportError")(defect.success); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes try { - console.error( - `[executor:mcp] execute defect correlation_id=${correlationId}`, - Cause.pretty(cause), - ); + console.error(`[executor:mcp] execute defect correlation_id=${correlationId}`, { + nativeElicitationFailed, + }); } catch { /* ignore logger failures */ } @@ -2383,9 +2377,14 @@ export const createExecutorMcpServer = ( smoke(input.code), ).pipe( Effect.catchCause((cause) => - Effect.as(Effect.logWarning("create-artifact smoke render was unavailable", cause), { - status: "ok", - } satisfies ArtifactSmokeRenderResult), + Effect.as( + Effect.logWarning("create-artifact smoke render was unavailable", { + causeKind: Cause.isCause(cause) ? "Cause" : "Error", + }), + { + status: "ok", + } satisfies ArtifactSmokeRenderResult, + ), ), ); const renderRejection = smokeRenderRejection(smokeResult); @@ -2896,7 +2895,7 @@ export const createExecutorMcpServer = ( console.error( "[executor] MCP session mode", JSON.stringify({ - ...capabilitySnapshot(server), + elicitationSupport: getElicitationSupport(server), elicitationMode: elicitationMode.mode, resumeEnabled: elicitationMode.mode !== "native", }), diff --git a/packages/plugins/graphql/src/sdk/errors.ts b/packages/plugins/graphql/src/sdk/errors.ts index 8f2d04d82c..f311e9c39e 100644 --- a/packages/plugins/graphql/src/sdk/errors.ts +++ b/packages/plugins/graphql/src/sdk/errors.ts @@ -13,6 +13,7 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass Effect.logError("graphql introspection request failed", cause)), + Effect.tapCause(() => + Effect.logError("graphql introspection request failed", { host: requestUrl.hostname }), + ), Effect.mapError( () => new GraphqlIntrospectionError({ @@ -318,8 +323,35 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( ), ); + let downloadedBytes = 0; + const responseText = yield* response.stream.pipe( + Stream.mapEffect((chunk) => { + downloadedBytes += chunk.byteLength; + return downloadedBytes > MAX_INTROSPECTION_BYTES + ? Effect.fail( + new GraphqlIntrospectionError({ + message: "Introspection response exceeds the 32 MiB limit", + reason: "response-too-large", + }), + ) + : Effect.succeed(chunk); + }), + Stream.decodeText(), + Stream.runFold( + () => "", + (text, chunk) => text + chunk, + ), + Effect.mapError((cause) => + Predicate.isTagged(cause, "GraphqlIntrospectionError") + ? cause + : new GraphqlIntrospectionError({ + message: "Failed to read introspection response", + reason: "network", + }), + ), + ); + if (response.status !== 200) { - const responseText = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))); const raw = responseText ? yield* Schema.decodeUnknownEffect(JsonTextSchema)(responseText).pipe( Effect.catch(() => Effect.succeed(null)), @@ -338,8 +370,8 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( }); } - const raw = yield* response.json.pipe( - Effect.tapCause((cause) => Effect.logError("graphql introspection JSON parse failed", cause)), + const raw = yield* Schema.decodeUnknownEffect(JsonTextSchema)(responseText).pipe( + Effect.tapCause(() => Effect.logError("graphql introspection JSON parse failed")), Effect.mapError( () => new GraphqlIntrospectionError({ diff --git a/packages/plugins/openapi/src/sdk/parse.ts b/packages/plugins/openapi/src/sdk/parse.ts index 8f2557dcb0..c41bfce753 100644 --- a/packages/plugins/openapi/src/sdk/parse.ts +++ b/packages/plugins/openapi/src/sdk/parse.ts @@ -1,5 +1,5 @@ import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; -import { Duration, Effect, Schema } from "effect"; +import { Duration, Effect, Predicate, Schema, Stream } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { JSON_SCHEMA, load as parseYamlDocument } from "js-yaml"; @@ -107,12 +107,29 @@ export const fetchSpecText = Effect.fn("OpenApi.fetchSpecText")(function* ( message: specTooLargeMessage(declaredLength, MAX_SPEC_TEXT_CHARS), }); } - const specText = yield* response.text.pipe( - Effect.mapError( - (_cause) => - new OpenApiParseError({ - message: "Failed to read OpenAPI document body", - }), + let downloadedBytes = 0; + const specText = yield* response.stream.pipe( + Stream.mapEffect((chunk) => { + downloadedBytes += chunk.byteLength; + return downloadedBytes > MAX_SPEC_TEXT_CHARS + ? Effect.fail( + new OpenApiParseError({ + message: specTooLargeMessage(downloadedBytes, MAX_SPEC_TEXT_CHARS), + }), + ) + : Effect.succeed(chunk); + }), + Stream.decodeText(), + Stream.runFold( + () => "", + (text, chunk) => text + chunk, + ), + Effect.mapError((cause) => + Predicate.isTagged(cause, "OpenApiParseError") + ? cause + : new OpenApiParseError({ + message: "Failed to read OpenAPI document body", + }), ), ); return specText; From ddc936b65a457ca8296cacbd4c394f75a8dfbfde Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:12:51 -0700 Subject: [PATCH 2/4] Keep safe tool sync error classification --- packages/core/sdk/src/executor.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 7e6b767b06..0f338405cd 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -4685,7 +4685,9 @@ export const createExecutor = Effect.logWarning("executor stale tool sync scan failed", { - errorType: typeof error, + errorTag: Predicate.isTagged(error, "StorageError") ? "StorageError" : "Unknown", }), ), ), From 75ad3cb3ea1f94c962d27dda396e6b9e8bcb6db0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:20:12 -0700 Subject: [PATCH 3/4] Keep security operations policy outside the public repository --- apps/cloud/SECURITY-OPERATIONS.md | 67 ------------------------------- 1 file changed, 67 deletions(-) delete mode 100644 apps/cloud/SECURITY-OPERATIONS.md diff --git a/apps/cloud/SECURITY-OPERATIONS.md b/apps/cloud/SECURITY-OPERATIONS.md deleted file mode 100644 index 7f9bb7f587..0000000000 --- a/apps/cloud/SECURITY-OPERATIONS.md +++ /dev/null @@ -1,67 +0,0 @@ -# Credential access and review - -This runbook defines the review process for the hosted application. A policy -is not evidence that a review took place. Keep dated review records outside -the public repository, with the scope, findings, owner, and follow-up actions. - -## Access - -- Store connected-service credentials in WorkOS Vault. Resolve credentials - only after tenant and subject authorization. Bind vault objects to their - owner and key context. Do not expose plaintext credentials in list or - metadata responses. -- Store deployment credentials in managed secret bindings. Give a service - only the credentials it needs. Separate production and development keys. -- Require MFA for human access to production infrastructure. Application - administration requires a fresh, session-bound second-factor verification. -- Use personal identities for human administration. Review provider team - membership and service credentials when access changes. Revoke access when - a person or service no longer needs it. -- Do not log passwords, tokens, cookies, request bodies, payment details, or - raw provider errors. Keep operation names, status codes, safe error classes, - source positions, and trace identifiers for diagnosis. - -## Machine keys - -Machine API keys support unattended integrations. They are distinct from -interactive sessions. A personal key acts as its owning member and cannot -satisfy an administrator verification. Organization keys cannot satisfy the -interactive administrator gate. Evaluate tenant and subject authorization on -requests; validate key validity with WorkOS. - -For rotation, create a replacement key, update the authorized workload, verify -its requests, then revoke the old key. Revoke immediately after suspected -exposure. Do not revoke active customer keys merely to demonstrate this -procedure. Use an isolated test identity for revocation tests. - -## Review procedure - -The service owner performs the following review weekly and after an access or -credential incident: - -1. Review WorkOS Vault access events for unexpected actors, unusual volumes, - or access outside the affected workload. Inspect metadata, not secret values. -2. Compare provider team access and service credentials with current needs. -3. Review authentication and billing failures in Cloudflare and Sentry. Use - safe trace identifiers to investigate. Check that diagnostics omit secrets. -4. Verify query-string redaction after deployments and changes to telemetry. -5. Record the date, evidence range, sample size, findings, and actions in the - private security evidence folder. Distinguish a sample from a full review. - -If access is unexplained, preserve safe evidence, determine the affected -scope, revoke or rotate the affected credential, and verify that the old -credential no longer works. Follow the incident process for any customer -notification; do not publish customer or secret data in issues. - -## Evidence limits - -A visible event sample establishes that those events were recorded. It does -not establish complete coverage, a retention period, or automated alerting. -Record configured retention and alert destinations only when verified in the -provider. This runbook does not claim that automated Vault alerts exist. - -External identity providers control their own passwords. Do not claim that -changing a federated password invalidates application sessions without an -observed or documented signaling and revocation path. WorkOS documents that -its own password reset revokes active WorkOS sessions; locally verified access -tokens remain usable until their acceptance window ends. From e0bfa02f945512a6972ae288c167da86a72836a8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:26:19 -0700 Subject: [PATCH 4/4] Preserve existing request and import size behavior --- apps/cloud/src/request-limits.ts | 37 ------------------- apps/cloud/src/server.ts | 5 +-- packages/plugins/graphql/src/sdk/errors.ts | 1 - .../plugins/graphql/src/sdk/introspect.ts | 36 ++---------------- packages/plugins/openapi/src/sdk/parse.ts | 31 ++++------------ 5 files changed, 11 insertions(+), 99 deletions(-) delete mode 100644 apps/cloud/src/request-limits.ts diff --git a/apps/cloud/src/request-limits.ts b/apps/cloud/src/request-limits.ts deleted file mode 100644 index a47fd614a9..0000000000 --- a/apps/cloud/src/request-limits.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** Maximum accepted request body, including streamed imports and MCP calls. */ -export const MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024; - -/** Read a bounded body before dispatch, so no handler can bypass the byte limit. */ -export const limitRequestBody = async ( - request: Request, - maxBytes = MAX_REQUEST_BODY_BYTES, -): Promise => { - if (!request.body) return request; - const reject = () => Response.json({ error: "Request body too large" }, { status: 413 }); - const declaredLength = request.headers.get("content-length"); - if (declaredLength !== null && Number(declaredLength) > maxBytes) { - await request.body.cancel(); - return reject(); - } - - const reader = request.body.getReader(); - const chunks: Uint8Array[] = []; - let bytes = 0; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: release the native Web stream reader on every exit before entering the Effect app - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - bytes += chunk.value.byteLength; - if (bytes > maxBytes) { - await reader.cancel(); - return reject(); - } - chunks.push(new Uint8Array(chunk.value)); - } - } finally { - reader.releaseLock(); - } - // No clone/tee: only the bounded body reaches downstream parsers and proxies. - return new Request(request, { body: new Blob(chunks) }); -}; diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 3d4fd78943..71905e352f 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -10,7 +10,6 @@ import { import * as Sentry from "@sentry/cloudflare"; import handler from "@tanstack/react-start/server-entry"; -import { limitRequestBody } from "./request-limits"; import { isAppOwnedPath, servedByAppPlane } from "./app-paths"; import { marketingProxyRequest } from "./edge/marketing"; import { passthroughResponse } from "./edge/passthrough"; @@ -305,9 +304,7 @@ const prewarmAppPlane = (ctx: ExecutionContext): void => { }; const cloudflareHandler: ExportedHandler = { - fetch: async (incoming, env, ctx) => { - const request = await limitRequestBody(incoming); - if (request instanceof Response) return request; + fetch: async (request, env, ctx) => { isolateRequestSeq += 1; // Public pages must not enter TanStack Start: its first-request dynamic diff --git a/packages/plugins/graphql/src/sdk/errors.ts b/packages/plugins/graphql/src/sdk/errors.ts index f311e9c39e..8f2d04d82c 100644 --- a/packages/plugins/graphql/src/sdk/errors.ts +++ b/packages/plugins/graphql/src/sdk/errors.ts @@ -13,7 +13,6 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass { - downloadedBytes += chunk.byteLength; - return downloadedBytes > MAX_INTROSPECTION_BYTES - ? Effect.fail( - new GraphqlIntrospectionError({ - message: "Introspection response exceeds the 32 MiB limit", - reason: "response-too-large", - }), - ) - : Effect.succeed(chunk); - }), - Stream.decodeText(), - Stream.runFold( - () => "", - (text, chunk) => text + chunk, - ), - Effect.mapError((cause) => - Predicate.isTagged(cause, "GraphqlIntrospectionError") - ? cause - : new GraphqlIntrospectionError({ - message: "Failed to read introspection response", - reason: "network", - }), - ), - ); - if (response.status !== 200) { + const responseText = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))); const raw = responseText ? yield* Schema.decodeUnknownEffect(JsonTextSchema)(responseText).pipe( Effect.catch(() => Effect.succeed(null)), @@ -370,7 +340,7 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( }); } - const raw = yield* Schema.decodeUnknownEffect(JsonTextSchema)(responseText).pipe( + const raw = yield* response.json.pipe( Effect.tapCause(() => Effect.logError("graphql introspection JSON parse failed")), Effect.mapError( () => diff --git a/packages/plugins/openapi/src/sdk/parse.ts b/packages/plugins/openapi/src/sdk/parse.ts index c41bfce753..8f2557dcb0 100644 --- a/packages/plugins/openapi/src/sdk/parse.ts +++ b/packages/plugins/openapi/src/sdk/parse.ts @@ -1,5 +1,5 @@ import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; -import { Duration, Effect, Predicate, Schema, Stream } from "effect"; +import { Duration, Effect, Schema } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { JSON_SCHEMA, load as parseYamlDocument } from "js-yaml"; @@ -107,29 +107,12 @@ export const fetchSpecText = Effect.fn("OpenApi.fetchSpecText")(function* ( message: specTooLargeMessage(declaredLength, MAX_SPEC_TEXT_CHARS), }); } - let downloadedBytes = 0; - const specText = yield* response.stream.pipe( - Stream.mapEffect((chunk) => { - downloadedBytes += chunk.byteLength; - return downloadedBytes > MAX_SPEC_TEXT_CHARS - ? Effect.fail( - new OpenApiParseError({ - message: specTooLargeMessage(downloadedBytes, MAX_SPEC_TEXT_CHARS), - }), - ) - : Effect.succeed(chunk); - }), - Stream.decodeText(), - Stream.runFold( - () => "", - (text, chunk) => text + chunk, - ), - Effect.mapError((cause) => - Predicate.isTagged(cause, "OpenApiParseError") - ? cause - : new OpenApiParseError({ - message: "Failed to read OpenAPI document body", - }), + const specText = yield* response.text.pipe( + Effect.mapError( + (_cause) => + new OpenApiParseError({ + message: "Failed to read OpenAPI document body", + }), ), ); return specText;