Security model
How the platform isolates surfaces, scopes credentials, and limits blast radius. Written for the curious dev, the marketer-friendly version is: it is layered, it is explicit, and it does not rely on the agent being well-behaved.
The four surfaces
Every action belongs to one of four surfaces, and each surface has a different identity and different grants.
| Surface | Who is acting | How identity is proven |
|---|---|---|
| agent | The workspace user driving this chat | Bound to the chat thread by the platform. |
| console | A workspace member viewing a Console page | Auth headers set by nginx after login (X-Auth-User-*). |
| site | An anonymous visitor or invitee on the public site | Site cookie payload ({u,n,em,p} for members, {g,lbl} for invitees). No headers. |
| trigger | A third-party webhook | Signed payload verified against the connector's signing secret. |
Approvals are scoped per surface. The agent posting to Slack from chat is a different, separately approved action than a Console page posting to Slack, even if both use the same Slack token.
OS-level isolation
Three Linux users, three sets of permissions:
agent— your chat workspace. Can read~/workspace/, can call connectors, cannot read Console or site code.console— the Console Flask process. Can read/home/console/http/, cannot read your workspace (exceptdownloads/via a shared group).site— the public site Flask process. Can read/home/site/http/, cannot read Console code, cannot read your workspace.
The platform itself runs as root for the API proxy, the connectors dispatcher, and the database. No surface authenticates as root.
Database isolation
Postgres peer auth maps OS users to roles of the same name. Grants and REVOKEs are explicit:
| Database | root |
agent |
console |
site |
|---|---|---|---|---|
system_db |
R/W | - | - | - |
console_db |
- | R/W | R/W | (REVOKE) |
site_db |
- | R/W | R/W | R/W (owner) |
console_site_db |
- | R/W | R/W | R/W |
The site role is explicitly REVOKED from console_db. A public visitor cannot reach internal state even if the site process is compromised, because the database itself refuses the connection.
Credential isolation
API tokens (Slack, HubSpot, Ahrefs, ...) live in a typed secret store. The agent can name a secret and ask the dispatcher to use it. The agent cannot read the secret value.
- Secrets are scoped by
secret_type_id(e.g.slack.bot_token). - Each secret can be granted to specific surfaces. A grant covers agent + console by default; site grants are separate and explicit.
- Each grant has the list of connectors it can be used with.
- Every invocation is logged with surface, connector, secret, args (redacted), duration, and result status.
If a Slack bot token is granted to agent + console but not to site, a public-site page asking to post to Slack returns not_approved with a message telling the user to go ask the agent to grant the site surface.
Network isolation
- Outbound HTTPS to non-loopback hosts is blocked by default. The agent asks for a domain allowlist via
request_domain_accesswith a stated reason. - Loopback (127.0.0.1) is never intercepted. Local services (LLM proxy on
:18080, api-proxy on:18081, console on:8080, site on:8090, Postgres on:5432) talk freely. - nginx terminates TLS for the public site, enforces the visibility mode, and routes per path. The public site app never sees TLS or unauthenticated traffic; nginx filters first.
Approval lifecycle
Every approval card has the same shape:
- The agent calls a
request_*tool. - The card lands inline in chat with a clear description (what is being asked, why, what the scope is).
- Owner or admin clicks Approve or Deny. The decision is sticky.
- The agent retries the original action; it now succeeds.
Approvals do not auto-expire. They can be revoked at any time from the workspace UI, and the next attempt will require a new card.
For agent surface, invoke_connector's first call surfaces the card automatically. For Console and site surfaces, the agent must explicitly call request_connector_approval({surface: 'console' | 'site'}). The two are separate ledgers.
Public-site hardening defaults
When the public site is open, the agent applies extra rules by default:
- Every visitor input is validated through a Pydantic v2 schema. Visitors lie.
- No per-visitor third-party calls. Anything that would cost money or rate-limit is pre-computed by a job or a Console-side workflow; the site only reads.
- Visitor-supplied content is sanitized before persistence. Strings that land in
site_dbmay be rendered later for another visitor; escape on read or validate on write. - Connector approval is separate from agent and console. The owner explicitly clicks the site card; there is no carry-over.
What this does NOT protect against
Worth naming explicitly:
- A workspace owner who shares a public-site URL with the world while the site is set to
openand the page is supposed to be private. The toggle is the lock; if you turn it off, anyone with the URL can read. - An owner who pastes a secret into chat or into a file. Treat secrets as approvals-only.
- A compromised LLM provider returning malicious output. The agent does not blindly execute LLM output as code; structured tool calls go through the typed framework, but free-form text in chat is just text.
- Bugs in third-party connectors. The agent reads the provider docs when an error category appears; recovery is documented per provider.
Reporting an issue
If you find a behavior that looks like a security gap, do not write it into chat where it ends up in this docs site. Surface it via the platform's standard report path (workspace owner contacts Letaido).
Under the hood. The defense in depth, in order: OS users + filesystem permissions, Postgres roles + grants/REVOKEs, the connectors dispatcher (typed args, scoped secrets, per-surface approvals, audit log), nginx (TLS + visibility mode + path routing), the outbound firewall (domain allowlist). The agent itself is the top layer of defense, but it is not the only one. Each lower layer would refuse a bad request even if the agent forwarded it.