AbstractGateway — Security guide¶
AbstractGateway secures the gateway API surface (/api/gateway/*) using an ASGI middleware:
GatewaySecurityMiddleware in src/abstractgateway/security/gateway_security.py.
Notes:
- /api/health is intentionally not protected.
- /api/triage/action/* uses signed action tokens and is not under /api/gateway (see src/abstractgateway/routes/triage.py).
- Vulnerability reporting policy: see ../SECURITY.md.
Default behavior¶
Security is on by default for /api/gateway/*:
- A plain
abstractgateway servewith no auth posture in its environment binds127.0.0.1(or the stored network mode), turns user accounts on and creates the admin accountdefault/admin(first-run.md). - Choosing
lanorinternetwith the network setting keeps user accounts on. - An explicit non-loopback
--hostwith neither user accounts nor a token refuses to start, and so does a weak shared token on a non-loopback bind.
Evidence: startup self-checks in src/abstractgateway/cli.py,
src/abstractgateway/first_run.py.
Explicit browser-console/browser-app setup:
export ABSTRACTGATEWAY_USER_AUTH=1
export ABSTRACTGATEWAY_DATA_DIR="$PWD/runtime/gateway"
abstractgateway serve --host 127.0.0.1 --port 8080
# Use this with Gateway user admin.
cat "$ABSTRACTGATEWAY_DATA_DIR/auth/bootstrap-admin-token"
API clients can send a Gateway user token:
Authorization: Bearer <token>
Tenant and user isolation¶
In token mode, ABSTRACTGATEWAY_AUTH_TOKEN is a gateway-level control-plane
token and maps to the local-admin principal. Treat that token as
full authority for the Gateway instance.
Hosted user-auth mode is enabled with ABSTRACTGATEWAY_USER_AUTH=1 or
ABSTRACTGATEWAY_AUTH_MODE=users. In that mode, Gateway bearer tokens resolve
to concrete principals with tenant_id, user_id, roles/scopes, and a token
fingerprint. GET /api/gateway/me returns the resolved principal and routing
mode. The presence of an auth/users.json registry file is readiness state; it
does not silently enable hosted user auth unless ABSTRACTGATEWAY_USER_AUTH_AUTO=1
is set for compatibility. Admin principals can manage users through:
GET /api/gateway/admin/users?kind=human|entity|all(defaultall)POST /api/gateway/admin/usersGET /api/gateway/admin/users/{user_id}?tenant_id=...PATCH /api/gateway/admin/users/{user_id}?tenant_id=...(enabledis the Active switch: false = signed out and unable to sign in; an admin cannot deactivate their own account,409 cannot_deactivate_self, nor the last active admin,409 last_admin;emailis the user's email address)DELETE /api/gateway/admin/users/{user_id}?tenant_id=...GET /api/gateway/admin/runtime-reservationsPOST /api/gateway/admin/runtime-reservations/{runtime_id}/transferPOST /api/gateway/admin/runtime-reservations/{runtime_id}/purge
Every user row carries a first-class principal_kind field ("human" or
"entity"); clients must read it (or the kind filter) instead of
re-deriving kind from the roles convention. Census asymmetry is deliberate:
GET /api/gateway/entities is the ENTITY census (homes on disk), while
?kind=entity here is the entity-PRINCIPAL census — homes created before
principal minting have no user row, so the two lists can legitimately differ
and neither may be derived from the other.
Entity principals (minted at entity creation) are shaped by the entities
lane, not the users lane: PATCH refuses token/rotate_token/roles/
runtime_id and DELETE refuses outright (HTTP 403 naming the lane). A
rotation would mint a live entity bearer that by design must not exist, and
a delete would remove the name-collision guard protecting the entity's
identity. enabled (the door-side disable), email, and scopes stay
editable. The guard lives in GatewayUserRegistry itself, so the config CLI
refuses the same writes.
Gateway stores user token hashes in <ABSTRACTGATEWAY_DATA_DIR>/auth/users.json
by default. Generated or rotated bearer tokens are returned once from the admin
create/update response and are never stored in plaintext.
Browser apps should exchange user bearer tokens for Gateway browser sessions
instead of storing bearer tokens. POST /api/gateway/session/login accepts a
Gateway user id and user token, validates them against the registry, and sets an
opaque signed session id plus a CSRF token as cookies. The JSON response body
does not expose those values. Gateway stores session records in
<ABSTRACTGATEWAY_DATA_DIR>/auth/sessions.json by default.
The session cookie is HTTP-only; the CSRF cookie is readable by the hosting app
so it can send the CSRF header. Both cookies use path / and SameSite=Lax.
Plain HTTP local-dev responses do not set Secure; HTTPS responses, including
requests forwarded with X-Forwarded-Proto: https, do set Secure.
Non-remembered sessions omit Max-Age; remembered sessions include one.
Session-authenticated mutating requests must send:
X-AbstractGateway-Session: <session id>
X-AbstractGateway-CSRF: <csrf token>
POST /api/gateway/session/logout revokes the session. Disabling, deleting, or
rotating the Gateway user invalidates existing browser sessions for that user.
Who can sign in depends on whether user accounts are on. With user
accounts on, every registry account (admin or not) can sign in, and each one
works in its own runtime (below). With user accounts off the gateway runs one
runtime, the operator's, so every signed-in person would share the operator's
runtime, capability defaults, endpoint profiles and workflows. In that mode
only accounts with the admin role can hold a browser session:
POST /api/gateway/session/loginanswers401for a non-admin account, withreason_code: "user_accounts_off_admin_only"and a message naming the two ways out: the gateway operator turns user accounts on, or the person signs in with an admin account.- A session that already exists for a non-admin account (for example one
created while user accounts were on) is refused and removed at its next
use. Every session path applies the same rule (
principal_barred_from_shared_runtimeinsecurity/sessions.py), including the browser-app sign-in handover. POST /api/gateway/admin/usersanswers409(samereason_code) instead of creating a non-admin account that could never sign in, andPATCH /api/gateway/admin/users/{user_id}refuses to remove theadminrole from an admin account in this mode.
Admin accounts sign in in both modes. The rule reads the same setting the service routing reads, so the two cannot disagree.
The last admin account is protected. DELETE /api/gateway/admin/users/{user_id},
and a PATCH that disables it or removes its admin role, answer 409 with
reason_code: "last_admin" when the target is the only enabled admin account
left (entity principals never count). Create or enable another admin first.
When user auth is active, the Gateway service composition root routes each principal to an isolated service/data plane under:
<ABSTRACTGATEWAY_DATA_DIR>/users/<tenant_id>/<runtime_id>/runtime
<ABSTRACTGATEWAY_DATA_DIR>/users/<tenant_id>/<runtime_id>/flows
Gateway rejects duplicate runtime_id values within the same tenant during
user creation and update. This keeps the default multi-user invariant at
1 user = 1 runtime. Deleting a user removes the credential but reserves the
retained runtime id for that principal, so another same-tenant user cannot be
assigned to retained data by accident. Reusing the same runtime id in a
different tenant remains valid.
Admins can intentionally resolve retained runtime reservations through
admin-only lifecycle routes. Transfer assigns a retained runtime to an existing
same-tenant user and reserves that user's previous runtime id. Purge requires an
exact confirm_runtime_id, deletes the retained runtime root under
<ABSTRACTGATEWAY_DATA_DIR>/users/<tenant_id>/<runtime_id>/, then releases the
runtime id for reuse. Regular users cannot list, transfer, or purge retained
runtime reservations.
Clients must not send authoritative user_id, tenant_id, runtime_id, or
workspace-root values. Runtime fields such as actor_id and session_id, and
references such as run_id, artifact_id, and memory owner_id, remain
correlation and lookup fields; they do not authorize access by themselves.
In hosted multi-user mode, the request path resolves a principal and routes it
to its own service. Gateway also applies a central
route-family authorization table for operator/admin surfaces. Admin-only route
families include user management, audit, process control, backlog/triage/report
operations, the deprecated /email/* aliases (the calling admin's own mailbox),
the per-user email switch, model residency mutations
(POST /models/load|unload|lock|unlock|download), session-wide prompt-cache
clearing (POST /sessions/{session_id}/prompt_cache/clear_all), server
workspace file helpers, server-workspace artifact import/export, and global
prompt-cache/bloc mutation routes. Host and residency reads —
GET /models/loaded, GET /models/context_estimate, GET /host/state,
GET /host/metrics/*, and GET /sessions/prompt_cache — are visibility every
authenticated client needs and serve any authenticated principal; anonymous
requests remain rejected. Regular users remain able to use
their own runtime data plane for run, ledger, artifact upload, discovery, and
runtime-scoped Core capability-default routes.
The route table is intentionally conservative around server filesystem access:
browser-local files should use /api/gateway/attachments/upload; server
workspace reads/imports/exports require an admin principal until a stronger
per-user workspace grant model exists. The one user-level exception is a run's
own folder: the person who started a run can list and preview that run's
workspace (GET /runs/{run_id}/workspace, /files, /content), confined to
it and with the built-in deny list applied
(api.md).
Capability discovery follows the same policy. Regular users can still discover
ordinary run, ledger, artifact, upload, provider/model catalog, KG, and
runtime-scoped defaults surfaces, but admin-only workspace artifact
import/export and provider prompt-cache controls are advertised as unavailable
with machine-readable admin_required metadata. Session-level prompt-cache
keys remain available for users; the private hash includes the current
principal scope, so two users using the same session id/provider/model tuple do
not collide in a shared provider control plane.
Hosted provider secrets are supported through Gateway provider connections.
Connections are stored under the relevant Gateway data plane, expose only
non-secret metadata and a virtual provider id such as endpoint:office-vllm,
and inject the raw key only into the transient Runtime provider call. Normal
users can manage user-scoped connections; Gateway-scoped connections require an
admin principal. The current capability-default cascade uses execution-host
Core defaults, then the Gateway/root Core config baseline, then the user's
runtime Core config override under that user's Gateway data plane. A stronger encrypted vault, audit model, and
bridge/delegated-tool propagation policy remain future hardening work.
Who sees which account¶
An admin sees every user and every entity (GET /api/gateway/admin/accounts,
admin-only: other accounts get 403). Anyone else sees only their own account
and the entities they created (GET /api/gateway/me/accounts, and
GET /api/gateway/me/accounts/{id}/activity for their own activity or an own
entity's). The console's Accounts page follows the same rule.
POST /entitiesrecords the creator (created_by: {tenant_id, user_id}) in the new entity's manifest. Entities created before this field existed have no creator and are visible to admins only; no creator is guessed and no manifest is rewritten.- Every entity route checks visibility first. An entity you may not see answers exactly like a missing one (404, same message), so names cannot be probed.
- Entity names belong to the whole gateway: creating an entity under a name another account already holds (an entity in any runtime, or a user account) answers 409 "That name is taken".
- Seeing an entity is not managing it: admin-only entity writes (state, tool policy, prompt, substrate, …) stay admin-only for the entities you created, and only an admin can suspend an entity or rotate a user's token.
- A gateway without user accounts is one shared world: every entity is visible.
An entity has no token to rotate (its credential is discarded when it is created) and cannot be deleted (its name is kept for life); an admin suspends it instead. Details: api.md.
Per-user email¶
Each user's mailbox (email.md) lives in that user's own data plane, resolved from the authenticated principal on every route; no path or body id selects another user's account, and entities have none.
- Credentials (password or OAuth tokens) are sealed with AES-256-GCM
(
<plane>/email/account/email/secret.enc, 0600 in a 0700 folder); the key is in the OS keychain, or a 0600 key file next to it on hosts without one. They are used in memory at the moment of a connection and never enter run variables, ledgers, events, logs, the audit log or API responses. The encryption protects copies of the data folder, backups and file-reading tools; code running as the gateway's OS user can reach the key. - TLS is verified on every IMAP, SMTP and OAuth connection (certificate chain and host name); there is no plaintext mode. A private CA file is an administrator setting.
- OAuth endpoints come from the provider preset for Google and Microsoft;
endpoint and scope overrides are refused (
403 email_oauth_override_refused), for administrators too. Only an administrator may useprovider: "custom", which brings its own client. The gateway's and the built-in OAuth client are sent only to their provider's own endpoints. - Turned off by an administrator means no connection at all: the user's Connect, Test and OAuth sign-in are refused before any connection.
- Administrators can turn email on or off per user and see its state, address and last error — never messages, recipient policies or credentials.
- Sending always passes the user's recipient policy (allowlist or denylist) and send limits; an agent's send to anyone but the user waits for approval.
- Inbound mail is untrusted data: it reaches a model inside a fixed frame and cannot change a run's recipients or tools. The mailbox is only read.
- Availability is the administrator's:
email,email_agent_tools(off by default) andemail_recoveryare gateway-wide defaults with per-user overrides; agents get email tools only when available, connected and switched on by the user — checked when toolsets are built and again on every tool call (the runtime's own send actions only need the account). - Recovery by email trusts the mailbox: whoever controls a user's mailbox can sign in as that
user. It is on by default for users with email; administrators can turn it off gateway-wide
(
email_recovery). - Recovery codes are single use, expire after 10 minutes, are stored as keyed hashes, are rate-limited per account and client address, and the request answer never reveals whether an account exists or has email. Issue and use are audited without the code.
- Audit: typed events (
email.connected,email.tested,email.disconnected,email.capability_changed,email.cursor_reset,email.message_unprocessable,email.notification_sent|failed,email.recovery_code_issued|used|refused) in<data_dir>/audit_log.jsonl.
A user-supplied IMAP/SMTP host is a connection the gateway makes on that user's behalf; deployments that must restrict outbound destinations should do so at the network layer.
Workflow registry ownership¶
Writing a workflow registry requires owning it. Under hosted user auth the
/api/gateway/bundles routes resolve to the calling principal's own bundle
directory, which that user may change freely. The gateway's own directory is
the shared set every user can see and run, so changing it requires an admin
principal.
One check covers every route that writes a registry — POST /bundles/upload,
POST /bundles/reload,
POST /bundles/{bundle_id}/deprecate, POST /bundles/{bundle_id}/undeprecate
and POST /visualflows/{flow_id}/publish — so a shared workflow cannot be
replaced through one route while another is restricted. The check runs before
the route looks the bundle up, so a non-admin gets 403 for a bundle that
does not exist as well. Since a non-admin account cannot be signed in while user accounts are off
(above), this check is the second, independent line of defence. POST /visualflows/{flow_id}/publish accepts a caller-supplied
bundle_id, bundle_version and overwrite, and installs into the same
registry as upload; it is gated on the same rule. Non-admin requests against
the shared registry return 403. Read routes are unchanged.
Workflows are archived, never deleted: DELETE /bundles/{bundle_id} answers
410 for everyone. POST /bundles/{bundle_id}/archive and /unarchive
(optional body {"bundle_version": "..."}) hide a bundle from lists and refuse
its new runs while the file and every past run stay; an admin archives shared
bundles, a user their own, and bundles that ship with the gateway (including
basic-agent) answer 409. PATCH /bundles/{bundle_id} {"description"}
follows the same rule (the owner, an admin for shared bundles, 409 for
shipped ones) and is audited as workflow.description.
Workflow availability¶
PUT /api/gateway/admin/workflows/{bundle_id}/availability with
{"available": false} (admin only; 403 otherwise) hides a shared workflow
from non-admins. One rule applies everywhere: GET /bundles (the list every
app picker reads), GET /bundles/{bundle_id}, its flows and download, run start,
scheduling and automation creation (403 with "This workflow isn't available to
users on this gateway. Ask an admin."). An app's default workflow keeps running
for everyone. The setting is stored in
<data_dir>/config/workflow_availability.json; archives in
<data_dir>/config/workflow_archive.json (shared bundles) and
<data_dir>/users/<tenant>/<user>/config/workflow_archive.json (a user's own).
Shared workflow catalog¶
Do not share workflows by pointing multiple users at another user's private
bundle directory. Private /api/gateway/bundles routes stay scoped to the
current principal's runtime. Shared/default workflows belong in the Gateway
workflow catalog:
- catalog versions are immutable by
scope + tenant + bundle_id + bundle_version + sha256; - admins move explicit default pointers instead of overwriting existing versions;
- catalog ACLs are checked at run start against the authenticated principal's tenant, roles, and user id;
- catalog runs execute in the requesting user's runtime by default;
- catalog run policy is Gateway-issued and HMAC-signed before it is handed to
Runtime state; client-supplied
_runtime.workflow_policyvalues are stripped; - private bundle inspection routes reject catalog-internal bundle ids, so catalog flow/schema inspection remains ACL-aware;
- deprecate/block/tombstone changes block new starts without deleting stored bundle bytes.
Catalog mutation routes are admin-only under
/api/gateway/admin/workflow-catalog/*. User-visible catalog discovery is
available at GET /api/gateway/workflow-catalog.
Origin allowlist (browser/origin defense)¶
If the request includes an Origin header, the middleware allows it only when
it matches the allowlist (glob-style patterns, fnmatch). The allowlist is
http://localhost:* and http://127.0.0.1:*, the pages served at the
addresses the gateway detects (http://<interface address, Bonjour or Tailscale
name>:<listening port> and https://<Tailscale name>, refreshed every 60 s;
see configuration.md),
plus the allowed_origins setting (console: Network → Reached through another address?; TUI:
Connection screen; CLI:
abstractgateway network set --allowed-origins https://gateway.example.com).
The setting is read per request: a change applies to the next request, no
restart. Each origin is validated (scheme://host[:port], no path, no trailing
slash); * and wildcard patterns are accepted only as typed and are flagged.
See configuration.md.
One more case is allowed without a list entry: an https page asking its own
address. The Origin is https:// plus the request's Host, and the request
itself arrived over TLS: either native TLS, or X-Forwarded-Proto: https
from a proxy on the gateway machine (the only peer whose forwarded headers
are believed). This is tailscale serve or a local nginx that keeps the
browser's Host. DNS rebinding cannot produce it. The browser writes the
Origin, a rebinding page is plain http://, and an https:// rebinding page
would need a certificate for its own name from the proxy on this machine.
A gateway started with ABSTRACTGATEWAY_ALLOWED_ORIGINS in its environment
uses that list instead (a deployment pin): every surface says "This gateway was
started with ABSTRACTGATEWAY_ALLOWED_ORIGINS in its environment" and reports
overridden_by_env: true; the saved setting applies once it starts without it.
Evidence: GatewayAuthPolicy.allowed_origins, _effective_allowed_origins(),
_origin_allowed() and _https_same_origin() in src/abstractgateway/security/gateway_security.py;
live_reverse_proxy() in src/abstractgateway/network_exposure.py.
Important nuance:
- FastAPI’s CORS middleware in src/abstractgateway/app.py is permissive, but origin enforcement for gateway endpoints is done by this security middleware.
- In a network exposure mode from the settings store, serve adds the
gateway's own discovered LAN origins (IP literals and <name>.local). A
foreign origin, including a DNS-rebinding name that resolves to your LAN IP,
is still refused (403) unless it is in allowed_origins.
Network exposure¶
The network exposure setting (configuration.md)
chooses localhost, lan or internet. What changes for someone else on
your network:
localhost(default for a first run): the gateway listens on127.0.0.1only. Nobody else can open a connection; every local process of every local user still can, which is why user auth stays on.lan: the gateway listens on every IPv4 interface. Anyone on the same network (and anyone on a VPN such as Tailscale whose address is listed) can reach the sign-in page and the API. The gate is authentication:lanis refused unless user auth will be on at the next start; unauthenticated requests answer 401, failed credentials are locked out per client address with a growing wait, and a browser page from a foreign origin is refused (403).lanandinternetare also refused when the gateway was started with read protection off (ABSTRACTGATEWAY_PROTECT_READ=0: unauthenticated reads would be answered as the admin). Whatlandoes NOT give you:- encryption: it is plain HTTP. Passwords, bearer tokens and the
session cookie cross the network in clear; the session cookie is
HttpOnly; SameSite=Laxbut notSecureover HTTP. Anyone who can sniff the network (shared Wi-Fi, a compromised router) can capture and replay a session. Uselanon networks you trust, or use a TLS proxy / VPN. - a smaller attack surface: every admin route is reachable to whoever holds an admin credential. Give each person their own account, keep the admin token off other machines, and prefer non-admin accounts for daily use.
- exposure of the browser apps: apps always listen on
127.0.0.1and are reached through the gateway at/apps/<app>/, which requires a gateway session for that app on every request (a signed-out page load goes to the console, anything else gets 401), relays only the app's own cookies (never the console's session or anAuthorizationheader), and tells the app the browser's real address (X-Forwarded-For, written by the gateway). The sign-in handover (/apps/handover/{code}) only works on the host it was minted for. An app version that does not announce it can be served this way is never served under/apps/and stays reachable from the gateway machine only. - engine and app installs for remote admins:
allow_engine_installdefaults to off on a non-loopback bind for callers on other computers. Someone at the gateway machine itself can still install (see Callers on this computer). internet: the same bind plus an explicit acknowledgement. The gateway does not terminate TLS and does not configure your router or firewall. Put a TLS reverse proxy (Caddy, nginx, Traefik) or a tunnel (Cloudflare Tunnel, Tailscale Funnel, ngrok) in front and expose that; add the publichttps://origin under Reverse proxy (allowed_origins), turn on Trust the proxy's client address (trust_proxy) only when your own proxy is in front of every request, and rate-limit at the proxy. Forwarding the raw port means plain HTTP on the internet: do not.
The mode is applied at the next start and serve --host/--port override it;
GET /api/gateway/network always says what is configured, what is running,
and why they differ.
Callers on this computer¶
Some defaults belong to the person sitting at the gateway computer and not to
the rest of the network: installing engines and apps
(allow_engine_install), opening a
run's folder in the file manager (open_supported on
GET /runs/{run_id}/workspace), and the caller_is_this_machine fact the
workspace routes report. One rule decides them all
(src/abstractgateway/security/same_machine.py):
- The caller's address is the socket peer.
serveruns uvicorn withforwarded_allow_ipspinned to127.0.0.1and::1(aFORWARDED_ALLOW_IPSvalue in the environment is ignored with a warning), soX-Forwarded-Foris believed only from a peer on this computer. - The browser apps' servers (the AbstractCode web server and the AbstractUIC
app server) run on this computer and relay requests with the browser's real
address in
X-Forwarded-For(overwritten, never appended) and their marker headerX-AbstractFramework-App-Proxy: <app id>. A browser on another computer that opens an app is therefore not local; a browser on this computer is. - The caller is local when that address is loopback or one of this host's own interface addresses. A remote computer cannot use this host's own address as the source of an established TCP connection.
Fail-safes:
- A request with the app-proxy marker but no
X-Forwarded-Foris never local (a proxy that dropped the header would make every browser look local). - While the gateway trusts a reverse proxy (
trust_proxy), a request relayed by an app server is never local: the reverse proxy hides the browser's address. For this rule the savedtrust_proxysetting decides first; theABSTRACTGATEWAY_TRUST_PROXYlaunch environment counts only when nothing is saved. - A forwarded header (
X-Forwarded-For,Forwarded,X-Forwarded-Host,X-Real-IP) sent by a peer that is not on this computer is never local, and neither is a request that carries a proxy header other thanX-Forwarded-For(a proxy this rule cannot read).
Native clients on this computer (the web console, the terminal console, the Assistant, the tray) call the gateway directly, so their loopback peer decides.
Desktop Assistant sign-in¶
The Assistant opened from the console or the tray is signed in through a
one-time code the gateway writes into a file only your account can read
(<data dir>/handover/<random>.json, mode 0600) and names on the Assistant's
command line (--gateway-handover-file); the code itself is never on a command
line or in the environment. The Assistant trades it at
POST /api/gateway/apps/desktop-handover, which answers only a direct caller
on this computer (no proxy header and no app-server session header; 403
otherwise), once, within two minutes (410 after). The resulting session is a
remembered session (30 days) of the user who clicked Open. See
apps.md and
architecture.md.
Workspaces: three levels¶
Thin clients (browser apps, bridges, the Assistant) start runs whose file tools
(list_files, read_file, write_file, …) touch the gateway's computer. A
workspace is a directory an agent may work in. Which workspaces a run may use is
decided by the gateway, never by the client, at three levels that share one
shape: a posture, a default mode and rows, each row Read-only
(ro), Read & write (rw) or Refused (deny).
- Deny everything, allow listed workspaces (
allowed_only): nothing is reachable except the listed workspaces. A refused row carves a sub-directory out of a listed one. - Allow everything, refuse listed workspaces (
any_except_denied): every directory is reachable at the default mode, except the listed workspaces, which are refused or carry their own mode.
The levels:
- Gateway (admin) = the eligible set.
GET/PUT /api/gateway/workspace/policy. Each row's mode is a CAP. The built-in refusals (the gateway's data folder, credential folders such as~/.ssh) always apply. A fresh gateway allows everything, read & write. - Account = the account's default subset (people and entities alike):
GET/PUT /api/gateway/workspace/policy/{account}. The account picks its own posture within the eligible set and its own rows, each inside the set and at most at its cap. Not configured = the gateway policy as is. A person sets their own; an entity's is set by an admin or the entity's creator. - Session = one conversation's subset, and run = a one-off payload
(Flow's run window, Observer's launch, an automation): the same shape,
checked against the gateway's eligible set (not the account default), stored
by the gateway on the conversation (
GET/PUT /api/gateway/sessions/{id}/workspaces) or carried in the start body (workspace).
A run gets the first that applies: run > session > account > gateway. For every
path its mode is the lower of the gateway's cap and that level's rule (refused
< read-only < read & write). Nesting: the most specific row wins (the
longest real-path prefix), refused rows included. Refusing /Users/me while
allowing /Users/me/projects (read & write) is valid at every level: the
child is reachable and the rest of /Users/me is refused. A refused row inside
an allowed one refuses that subtree. Caps still bind: a row below the gateway
never exceeds the gateway's cap at its path (the gateway's most specific row
there), so a child of a read-only gateway row stays read-only, and a child of a
refused gateway row is eligible only where the gateway lists it. The built-in
refusals are absolute: a read-only or read & write row inside one is refused
at every level with '<path>' is inside the built-in refused workspace
'<built-in>'. (the one exception: a conversation folder of the account's own
data plane, below). The effective set, its line, the dry run, the run's file
tools, the server file routes, the workspace browser and the command sandbox
all apply this one rule. GET
/api/gateway/workspace/effective/{account}[?session=] returns the result and
one line that every surface shows verbatim, for example
Deny everything, allow listed workspaces · /Users/me/Pictures (rw) · /Users/me/Documents (ro),
next to the gateway's own line (gateway_summary), for example
Allow everything, refuse listed workspaces (rw) · /secrets (refused) · /archive (ro).
A write outside the eligible set or above a cap is refused (400
workspace_refused, one sentence, the offending path); the API is in
api.md.
Each conversation also keeps its own private workspace in its account's
data plane (protected by the built-in refusals, so another account's agents
never read it). It is always read & write for that run and never listed. A run
that names no workspace works there: a relative path such as out.txt is
written to <data dir>/workspaces/session-…/out.txt (a run without a
conversation gets its own <data dir>/workspaces/<run> folder). The run's
allowed workspaces are listed to the agent with their paths and modes, in the
workspace context of every tool-using call:
Default working directory: "<data dir>/workspaces/session-…"
Allowed workspaces:
"/Users/me/Pictures" (read & write)
"/Users/me/Documents" (read-only)
Under "Allow everything, refuse listed workspaces" a last line gives the mode
of everything else, for example Everything else: (read-only). Refused
workspaces are never listed as allowed. There is no "Shared workspace" line any
more (a stale workspace_shared_path sent by a client is dropped).
Enforcement reads only the effective set:
- Run starts (
POST /runs/start,/runs/schedule, entity summons, the automation definitions): a one-offworkspaceoutside the eligible set or above a cap is refused; aworkspace_root(for example the folder an app was launched from) is accepted only when the run reaches it; a legacyworkspace_allowed_pathslist may only narrow; a client that sendsworkspace_access_mode: "all_except_ignored"is refused. Refusals are 400s with a sentence; nothing is silently dropped. A launch folder gets no special trust: when the run does not reach it, a client asks the person to add the workspace. - Every run's tool sandbox, whatever started it (HTTP, the Telegram, email
and agora bridges, schedules, automations, entities), is bound by the host
(
run_workspace_guard.apply_workspace_policy), which resolves the level again and CLAMPS a forwarded one-off (rows outside the set dropped, modes lowered), never widens, and never silently: each clamped row is recorded on the run with its sentence (_gateway_workspace.clamped, shown byGET /runs/{id}/workspace): - "Deny everything, allow listed workspaces" at either level →
workspace_or_allowedwith the reachable workspaces. - "Allow everything, refuse listed workspaces" at both levels →
all_except_ignored. Only the gateway sets this mode, never a client. - Refused rows →
workspace_ignored_paths. AbstractRuntime resolves a path by the longest prefix among the run's own folder, the allowed paths and the refused paths (a tie is refused), so a refused parent with an allowed child reaches the runtime exactly as the gateway computed it. - Read-only workspaces →
workspace_read_only_paths. - A read-only default → every directory is read-only except the run's own
folder and the read & write workspaces (
workspace_writable_paths, AbstractRuntime; a client's own value is dropped, and child runs inherit the parent's exactly). Writes into a read-only workspace are refused with a sentence; reads work. - The level and its line are recorded on the run (
_gateway_workspace.{level, summary}), so the ledger and replay show what the run could use. - A workspace inside the gateway's data folder (for example another conversation folder of the same account) counts only for the account whose own data plane holds it. The host lifts the built-in data-folder deny for that path, for that account only, never for another account's plane.
- The run workspace browser serves a launch folder only while the run still
reaches it. The server file routes (
/files/*, admin) use the given root or the first read & write workspace as their base and the other reachable workspaces as mounts; exports into a workspace that is not read & write are refused.
Every change is recorded in the audit log as workspace_policy_changed
{scope: gateway|account|session|migration, actor, changed, account?, session_id?}.
Every entity has a gateway account. Entities get theirs at creation; homes
created before entity accounts existed get one at serve start, once, minted
like a creation (roles entity, token discarded) and audited as
entity_account_created (reason migration). A record of the same name that
is not an entity account is never adopted.
Migrations run once, at serve start or at the first read, and never reach beyond the new ceiling:
- From round 9 (shared workspace + "accounts narrow only"),
_migrated.workspace_policy_v2: the gateway posture becomes "Allow everything, refuse listed workspaces" (operator decision), keeping its default mode; the old shared workspace becomes a listed read & write row; existing rows are kept with their modes as caps; each account entry becomes a configured account layer under the same posture with its own read-only/refused rows and lowered default. - From the older model (whitelist/blacklist access modes, per-user allow/deny
lists, launch-folder trust, "Any folder (old clients)"),
_migrated.workspace_policy_v1first, then v2: the old workspace root (only when one was configured) becomes the listed read & write row; the old extra workspaces and every account's allowed folders become read & write rows; an account that did not have a folder another account had gets a refused row for it; old refused folders become refused rows (gateway's or the account's); launch folders trusted under the old model are not added. When no root was configured, nothing counts as "already reachable": every old folder becomes a row (the old runtime's guess, the folder the gateway was started in, is never used). - Repair of a store migrated by gateway 0.13.0,
_migrated.workspace_policy_v1_repair: 0.13.0 filtered the old folders by that guess (usually your home folder) and then kept no row for it, so folders under it disappeared from the list (access was not lost: the posture allows everything not refused). Once, at serve start or the first read, the gateway recomputes the old migration from_migrated.workspace_policy_v1.oldand adds the rows it lost (gateway rows, and the accounts' refused rows); a path the policy already lists keeps its current mode, other rows are untouched. The restored rows are recorded there and in the audit log (oneworkspace_policy_changedline per restored row, scopemigration_repair, with its path, mode and account); the restored rows are ordinary rows in the console; it never runs twice.
Missing folders are dropped and listed in the settings store under
_migrated.workspace_policy_v1 / _v2, next to the old blocks. The old runtime-config
keys (workspace_root, workspace_mounts, workspace_allowed_paths,
workspace_blocked_paths, workspace_default_mode, trust_client_launch_folder,
client_workspace_scope_overrides, user_workspace_policies) are refused on
write; the ABSTRACTGATEWAY_ALLOW_CLIENT_WORKSPACE_SCOPE /
ABSTRACTGATEWAY_TRUST_CLIENT_WORKSPACE_SCOPE variables are no longer read.
Built-in deny list¶
Whatever the workspace policy allows, the gateway's data folder and the
credential and configuration folders of the gateway's user account (~/.ssh,
~/.aws, ~/.gnupg, ~/.config/gcloud, ~/.kube, ~/Library/Keychains,
~/.abstractgateway, ~/.abstractcode, ~/.abstractassistant,
~/.abstractcontinuum, ~/.abstractcore) are:
- never listed or served by the workspace browser, for anyone, even when a run's folder contains them;
- denied to every run's file tools, as whole-folder rules
(
workspace_builtin_deny_prefixes) with the run's own folder inside the data folder as the one exception (workspace_builtin_allow). Clients cannot send these two entries (they are dropped), and the rules are enforced without being written into the model's prompt. An admin can turn the run side off with theworkspace_builtin_denysetting; the browser keeps hiding the folders.
Evidence:
- Policy model (three levels), effective set, migrations: src/abstractgateway/workspace_policy.py; the session level: src/abstractgateway/session_workspaces.py; entity accounts: src/abstractgateway/entity_accounts.py
- Every run start: src/abstractgateway/run_workspace_guard.py (called from WorkflowBundleGatewayHost.start_run)
- Client scope clamping: src/abstractgateway/routes/gateway.py (_sanitize_run_workspace_policy, _files_scope, _browse_workspace_root)
- Browse and preview: src/abstractgateway/workspace_browse.py
- Runtime tool scoping: abstractruntime/integrations/abstractcore/workspace_scoped_tools.py
- Tests: tests/test_gateway_workspace_policy_r11.py, tests/test_r11w1_levels.py, tests/test_r11w1_real_boot_migration.py, tests/test_gateway_workspace_policy_enforcement.py, tests/test_r12w2_nesting.py (the nesting rule)
Canonical public server paths use rel/path for the base workspace and
mount_alias/rel/path for the other folders. When two folders share the same
basename, Gateway emits deterministic digest-suffixed mount aliases so the
public path string stays stable across discovery, import/export, and Runtime
execution.
Command sandbox¶
Every tool that starts a process (execute_command, shell_exec,
execute_python, the local helpers: AbstractRuntime's SANDBOXED_TOOL_NAMES) runs inside an
operating-system sandbox built from the run's effective workspaces. These
are the same keys the file tools read, so the two cannot disagree: the run's
private workspace read & write, the allowed workspaces with their modes, the
refused workspaces, the built-in refusals and the posture's default mode. The
command string is never parsed. cd, $(…), symlinks, scripts and
interpreters are all confined by the kernel:
- macOS:
/usr/bin/sandbox-execwith a generated profile. Under "Deny everything, allow listed workspaces" it denies reading and writing user data (/Users,/Volumes,/private/var/root, the gateway user's home) and the shared temp folders (/private/tmp,/private/var/folders), where other processes leave files. System folders (/usr,/System,/Library,/opt,/usr/local,/Applications) stay readable, because commands need their programs and libraries, but nothing outside the listed workspaces and the run's private temp folder can be written. A refused workspace is denied wherever it is. - Linux: bubblewrap (
bwrap) when installed; Landlock (kernel 5.13 or later) for "Deny everything, allow listed workspaces" when bubblewrap is missing. - Anything else (Windows, Linux without either): the command is refused with one sentence and the run continues.
Every command starts from the gateway's scrubbed environment. This is the
same scrub the gateway applies to the apps it starts: nothing named
ABSTRACTGATEWAY_* / ABSTRACTCORE_*, and no *_TOKEN, *_SECRET,
*_API_KEY, *_PASSWORD or *_KEY. Each command also gets a private
TMPDIR inside the run's workspace. The gateway sets this host policy once
per process at boot (command_sandbox.configure_at_boot, from
start_gateway_runner; AbstractCore's configure_host). From then on a
spawning tool call that does not carry a run's sandbox is refused.
abstractgateway serve --unsandboxed-commands (also on the split
abstractgateway runner) re-enables commands on a host with no sandbox.
They then run with the gateway's own file access, still with the scrubbed
environment. The flag is off by default and has no environment variable. Where
a sandbox exists it changes nothing. With --reload it is ignored (the app
runs in uvicorn's reloader child). It is audited at boot as
command_sandbox_configured
{actor: "system:serve"|"system:runner", source, kind, state, line, unsandboxed_commands_allowed, env_keys}.
State, not a control. GET /api/gateway/workspace/policy and GET
/api/gateway/discovery/tools carry command_sandbox {state:
sandboxed|partial|unsandboxed|refused, kind, line, sentence,
unsandboxed_commands_allowed, configured, flag}. The console shows line
under the Accounts head, with sentence as its tooltip, and the terminal
console shows it on its Workspaces page:
Commands sandboxed: macOS sandbox-exec(orLinux bubblewrap)Commands refused: no sandbox on this hostUnsandboxed commands allowed (flag)
In /discovery/tools, each process-spawning tool row carries sandboxed:
true|false and sandbox (for example "Sandboxed to this run's workspaces"),
so the apps' tool cards can show the state.
Evidence per command. The runtime stamps every spawning call with the
paths it enforced (the hidden _sandbox argument, recorded with the tool call
in the run ledger). The tool result carries sandbox: {kind, label, posture,
default_mode, private_workspace, tmpdir, allowed: [{path, mode}], refused: [...],
builtin_refused: <count>} and the line Sandbox: macOS sandbox-exec, or
Sandbox: none — commands refused on this host.
Nested workspaces follow the most-specific-row rule on every platform: an allowed workspace inside a refused folder is reachable (read & write when it says so), the rest of the refused folder is not, and a refused folder inside the allowed one refuses its subtree again. On Linux, bubblewrap binds the allowed folder after masking its refused parent; Landlock grants the allowed folder alone.
Browser probe¶
browser_probe (the render check for a page an agent wrote) never opens a
local page as file://. The page is served to the headless browser from a
private loopback origin (http://<random name>.localhost:<port>) that answers
only for files the run may read: the same scope as the commands (private
workspace, allowed workspaces read-only or read & write, refused workspaces,
built-in refusals, most specific row first). Relative and root-relative links
keep working; a file outside the scope answers 403, file:// URLs are never
loaded, and the report lists both under "Local files BLOCKED". A local page
outside the run's workspaces is refused before the browser starts, and on a
gateway host a probe call that carries no run scope is refused. Remote
(http(s)://) targets are unchanged.
Evidence: src/abstractgateway/command_sandbox.py, src/abstractgateway/cli.py
(--unsandboxed-commands), AbstractCore abstractcore/tools/sandbox.py,
abstractcore/tools/browser_tools.py (_LocalOrigin),
AbstractRuntime workspace_scoped_tools.py (sandbox_stamp). Tests:
tests/test_r12w2_command_sandbox.py, tests/test_r12w2_nesting.py.
Common security env vars¶
All are loaded by load_gateway_auth_policy_from_env() (see src/abstractgateway/security/gateway_security.py).
Enable/disable¶
ABSTRACTGATEWAY_SECURITY=1|0(default: enabled)
Tokens¶
ABSTRACTGATEWAY_AUTH_TOKEN(single shared secret)ABSTRACTGATEWAY_AUTH_TOKENS(comma-separated list)ABSTRACTGATEWAY_USER_AUTH=1orABSTRACTGATEWAY_AUTH_MODE=users: enable file-backed user principals and per-principal service routingABSTRACTGATEWAY_USER_AUTH_AUTO=1: compatibility mode that also enables user auth when the registry file existsABSTRACTGATEWAY_USERS_FILE: optional user registry path; defaults to<ABSTRACTGATEWAY_DATA_DIR>/auth/users.jsonABSTRACTGATEWAY_SESSIONS_FILE: optional browser session registry path; defaults to<ABSTRACTGATEWAY_DATA_DIR>/auth/sessions.jsonABSTRACTGATEWAY_SESSION_TTL_S: default browser session lifetime in seconds (default: 8 hours; bounded)ABSTRACTGATEWAY_REMEMBER_SESSION_TTL_S: browser session lifetime when an app requests "remember me" (default: 30 days; bounded)
Protect reads vs writes¶
ABSTRACTGATEWAY_PROTECT_WRITE=1|0(default:1)ABSTRACTGATEWAY_PROTECT_READ=1|0(default:1)ABSTRACTGATEWAY_DEV_READ_NO_AUTH=1|0
Dev escape hatch: allow unauthenticated reads from loopback only.
Limits (abuse resistance)¶
ABSTRACTGATEWAY_MAX_BODY_BYTES(default:10MB)
Applies to every mutating request. Oversized requests are rejected with413naming both sizes — bodies are never truncated. The default is sized for authored documents (a VisualFlow save is a whole workflow, not a small API payload), not just for abuse resistance.ABSTRACTGATEWAY_MAX_ATTACHMENT_BYTES(default:25MB)ABSTRACTGATEWAY_MAX_BUNDLE_BYTES(default:75MB)ABSTRACTGATEWAY_MAX_CONCURRENCY(default:64)ABSTRACTGATEWAY_MAX_SSE(default:32)
Auth lockout (brute-force safety net)¶
ABSTRACTGATEWAY_LOCKOUT_AFTER(default:5)ABSTRACTGATEWAY_LOCKOUT_BASE_S(default:1.0)ABSTRACTGATEWAY_LOCKOUT_MAX_S(default:60.0)
Audit log (write requests)¶
ABSTRACTGATEWAY_AUDIT_LOG=1|0(default: enabled for writes)ABSTRACTGATEWAY_AUDIT_LOG_MAX_BYTES(default:50MB)ABSTRACTGATEWAY_AUDIT_LOG_ROTATIONS(default:10)ABSTRACTGATEWAY_AUDIT_LOG_HEADERS(comma-separated allowlist; default:x-client-id,x-client-version,x-forwarded-for)
Reverse proxies¶
X-Forwarded-Forfrom a proxy on the gateway machine (loopback peer, such astailscale serve) is always used for IP attribution (audit log) and lockout tracking. Thetrust_proxysetting (console: Network → Reached through another address? → Trust proxies on other machines; TUI: Connection screen checkbox; CLI:abstractgateway network set --trust-proxy on|off) extends that to a proxy on another machine. Read per request: it applies to the next request. Only when your own proxy sits in front of every request; otherwise any client chooses the address the gateway sees. The ephemeral tray token never honours it (raw socket peer only).- Trust proxy follows one rule everywhere (IP attribution, lockouts, the
same-machine rule and the network status): the saved setting first, and the
ABSTRACTGATEWAY_TRUST_PROXYenvironment variable only while nothing is saved. Once the switch has been saved, the variable no longer applies to that data folder; useabstractgateway network set --trust-proxy on|off(see Callers on this computer).
Production checklist (minimal)¶
- Run behind TLS (reverse proxy) and bind
--host 127.0.0.1(proxy in front) or lock down your network if binding0.0.0.0. - Use a strong random token and list exact origins in
allowed_origins(avoid public wildcards). - Keep
ABSTRACTGATEWAY_SECURITY=1.
Related docs¶
- Configuration overview: configuration.md
- API overview: api.md
- FAQ: faq.md
OpenAI API¶
The OpenAI-compatible API at /v1 is stopped by default. In Protected mode a caller's API key is their own gateway token, resolved by the security middleware; the gateway forwards authenticated requests to Core with its own internal credential, so a caller's token never reaches Core, and refused keys count toward the per-address lockout. Open mode serves direct local, LAN and VPN clients without a key, never through a proxy or in Internet mode, and Core keeps stored cloud-provider credentials for authenticated callers. Who can connect filters on the client address (the socket peer, or X-Forwarded-For from a proxy on this machine or a trusted proxy); Anywhere requires Internet mode with its acknowledgement and Protected. Request fields that would re-route a provider or carry a credential (base_url, api_key, provider, headers, ...) are refused with 400, so a caller cannot make the gateway connect to an address of its choosing. Browser pages without a key are limited to the accepted origins. Every request is one audit-log line (no prompts or replies). See openai-api.md.