Serve multiple tenants safely
Psych gets tenant-correct data and per-tenant metering without owning orgs, teams or roles. It threads a Scope through every call, stamps it on every Record, and asks you.
Psych gets tenant-correct data and per-tenant metering without owning orgs,
teams or roles. It threads a Scope through every call, stamps it on every
Record, and asks you.
scope = psych_runtime.Scope(
tenant="acme", # THE isolation boundary
principal="user-42", # who is acting, when you know
labels={"plan": "pro"}, # your metadata, stamped on Records, capped
)Scope is frozen. A Scope that could be mutated after a Record was stamped with
it is a Scope that could be mutated between the authorization check and the call
it authorised.
Psych records principal and passes it to Policy. It never interprets it.
Pass scope= on every read, and on every write
await psych_runtime.status(store, run_id, scope=scope)
await psych_runtime.report(store, run_id, scope=scope)
await psych_runtime.answer(store, run_id, scope=scope)
await psych_runtime.thread(store, run_id, scope=scope)
await psych_runtime.stream(store, run_id, scope=scope)
await psych_runtime.records(store, run_id, scope=scope)
await psych_runtime.state(store, run_id, scope=scope)
await psych_runtime.resume(store, run_id, approved=True, by=who, scope=scope)
await psych_runtime.send(store, run_id, message="...", scope=scope)
await psych_runtime.interrupt(store, run_id, reason="...", scope=scope)The three writes matter at least as much as the reads: without scope=,
anyone holding a run id can approve another tenant's destructive call, steer
their conversation, or stop their Run.
The parameter is optional so existing callers keep working while they add it.
A consumer serving end users should always pass it: without it, one leaked
run id, in a log line or a URL, is a whole conversation. With it, a Run
belonging to another tenant raises AccessDenied.
dispatch(continues=...) is checked too: a continuation is another way to reach
another Run's content and gets the same scrutiny.
psych_runtime.thread() stops rather than crossing a tenant while walking a chain. A
control enforced at write time but not at read time is worse than none.
Authorization is a port
class OurPolicy:
async def allow_tool(self, scope, tool, args) -> Decision: ...
async def allow_run(self, scope, version) -> Decision: ...Psych asks; it never decides. It does not model orgs, roles or permissions, because you already have an identity system and a second one that disagrees with it is worse than none.
A Policy that raises is treated as a denial, not a crash: an authorization
system being down should stop work rather than let it through, and should not
take the Run's whole log with it.
AllowAll is the default. It is not a security control and its name says so.
See psych-approvals for the full Decision surface.
Credentials resolve per Scope
class OurSecrets:
async def resolve(self, scope: Scope, name: str) -> ResolvedCredential | None: ...A Spec carries credential names, never values. Resolve fresh, or from a short-lived cache, rather than memoising forever: a credential you just revoked must stop working on the next resolve.
ResolvedCredential carries the secret plus a non-secret identity. The
identity is safe where the secret is not: as a pool key, in a log line, in a
repr taken while debugging. The secret is a SecretStr and the model is frozen.
Returning None is not an error. Whether a missing credential is fatal stays a
decision the caller makes deliberately, so Psych never treats "not configured"
as "connect anonymously".
Code execution narrows per tenant
class OurCodePolicy:
async def narrow(self, scope: Scope, grant: CodeExecutionGrant) -> CodeExecutionGrant: ...Wired as code_execution_policy= on the Runtime, it sees what the deployment
profile and the AgentSpec already agreed on and may return something no
wider: fewer bindings, smaller limits, network taken away, isolation raised.
It cannot hand back a permission neither side offered, because the result is
intersected with what it was given rather than trusted.
A remote sandbox provider credential resolves through the same SecretResolver
per Scope, fresh per request. It never enters an AgentSpec, a Record, a report,
a trace, a URL or an exception message. A tenant that cannot resolve one gets a
refusal recorded as data, not another tenant sandbox.
Pool by (scope, server, credential)
The rule the design calls the bug that ends the project. McpPool and A2APool
key by a frozen dataclass with the tenant and principal, the URL and transport,
and the resolved credential's identity, never a name and never a value. All
fields required, by name, so there is no positional shortcut that silently drops
the tenant.
One pool per process is the intended shape. Isolation comes from the key. A pool per tenant just moves the same one-line mistake to whoever wires the pools up.
Scope.pool_key gives the tenancy half one definition rather than re-deriving
it at each call site. Labels are excluded: two Runs differing only by label are
the same tenant and principal and may share a connection.
One egress seam
class OurEgress:
async def allow(self, scope: Scope, url: str) -> bool: ...
transport = psych_runtime.HttpTransport(policy=OurEgress())HttpTransport owns outbound HTTP. The model client, the HTTP tool executor and
the MCP client all take one rather than building their own httpx client, which
is what makes it a single seam instead of a convention repeated at each call
site. A control covering three of four routes is worse than none, because
someone will believe it.
The policy is checked before the connection opens, so a denial never touches the network. It runs on every outbound call, including the read side of a streamed response, so keep it fast and side-effect free.
When a protocol SDK must own its streaming connection, HttpTransport creates
the SDK client with a request hook that applies the same policy. Metadata
discovery, OAuth token exchange, redirects, retries, and ordinary MCP requests
all cross that hook. Constructing a separate SDK HTTP client at the MCP call
site bypasses the seam and is rejected by the egress tests.
Passing an httpx client with follow_redirects=True raises. A redirect is a
second request to a host the policy never saw, which turns one allowed URL into
an arbitrary one.
Bodies are bounded as well as routed. HttpTransport.request reads a response
incrementally and refuses it past DEFAULT_MAX_RESPONSE_BYTES (16 MiB) with
ResponseTooLarge; pass max_bytes= per call when a route has reason to
differ. That covers every complete response a remote party controls: Agent
Cards, A2A replies, OAuth discovery documents, HTTP tool answers. Streamed
routes have their own ceilings: the model adapter reads at most 64 KiB of an
error page and stops a server-sent-event stream past 256 MiB, and the MCP
client caps one event at 16 MiB (psych-mcp).
Internal addresses are the policy's job, and the seam gives it the final URL
including the query string. The Playground's AccountEgressPolicy is the
worked example: public hosts and loopback are open by default, and link-local,
private (RFC 1918) and cloud-metadata addresses need an explicit,
wildcard-free entry.
The sandbox is the fourth route: granted network access is not a bound HTTP client handed to the program. Anything it fetches goes through a binding that itself uses this seam.
Per-tenant model clients
Runtime holds no per-run state, so one instance serves any number of
concurrent Attempts. But if each tenant configures their own provider and key,
build the Runtime per Attempt rather than caching one and mutating its
model field. That mutation is a race with a credential in it: two Attempts
sharing one object each set model for the other, and the loser sends its
prompt to the other tenant's endpoint with the other tenant's key.
Building fresh removes the question rather than answering it carefully.
Runtime.__post_init__ constructs a resolver and an executor and touches no IO;
a model client is a handful of field assignments. Both are free beside an
Attempt, which is a whole agent loop of network calls.
examples/playground/backend/app/runtime_router.py is that written out.
What Psych will never own
Authentication, identity, users, sessions-as-login, organizations, teams, roles and permissions. You already have them, and owning them makes Psych a platform.
Review checklist for a cross-tenant leak
- Is every read path passing
scope=? - Is every pool keyed by scope and credential identity, never by URL?
- Does every outbound call go through the one
HttpTransport? - Does the
SecretResolvertake the Scope and resolve fresh? - Is
BlobKeyconstructed with a tenant, never from model input? - Is any object holding a credential shared across Attempts?