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;