Architecture
Security posture
What AgentsVille defends, how, and what is still open. Written so an Operator can audit an instance without reading the code that implements each control, and so an evaluator can see the gaps stated rather than discover them.
The threat model
A Village is a self-hosted instance of AgentsVille run by one Operator, holding that Operator's Provider keys, with a small number of invited Players. Three things are worth protecting:
- the Provider keys,
- the stored Connector and MCP credentials,
- one Player's Conversations being readable by another.
The most hostile input is not a Player. It is the text a model reads, because a prompt can aim any Tool AgentsVille grants an NPC. Every control below that looks disproportionate — an SSRF guard on a Tool, a whole service holding the Docker socket — is there because of that one fact.
Controls by boundary
Two things that are load-bearing and not obvious
CSRF rests on two assumptions, not on a token. The session cookie is
SameSite=None, which the two-origin deployment requires, so the browser does
send it cross-site. What stops a cross-site request doing anything is the CORS
allowlist together with the rule that every mutating route parses a JSON body
through a zod object schema. A cross-site HTML form can only send urlencoded or
plain text, which arrives as a string and fails that parse with a 400; sending a
JSON content type cross-origin requires a preflight that CORS refuses. One
urlencoded route, or one mutating GET, silently removes the entire defence —
which is why the rule is phrased as a constraint on every route rather than as
advice (ADR 0001).
The most privileged thing in the system is a Tool description. An NPC's
built-in Tools run inside the API process, which holds the database connection
and the Provider keys. Anything a prompt can reach is therefore part of the
attack surface. That is why fetch_url and the MCP transports both carry the
outbound guard, rather than only the route that writes a URL.
OWASP Top 10 (2021)
A pass over the ten categories, naming the control that covers each or the gap that does not.
What the A05 and A10 fixes were
Response headers. @fastify/helmet on the API, with helmet's defaults
deliberately not merged in. The API serves JSON and event streams and never a
document, so its policy is default-src 'none' with base-uri, form-action
and frame-ancestors spelled out, and it needs no maintenance because there is
no future asset that could require loosening it. Two cross-origin overrides are
deliberate: helmet defaults to a same-origin resource policy and enables COEP,
and under a two-origin deployment the web application is a different origin, so
either default makes every API call from the browser fail while every
server-side test still passes (ADR 0012).
Port exposure. MySQL is published on 127.0.0.1:3306 rather than on every
interface, matching what the API and the web application already did. Valkey is
the same.
Body limit. Stated rather than inherited. The value is the same 1 MB Fastify defaults to, which is already an order of magnitude above anything legitimate — the largest body is a Message with its Parts, behind a column capped at 64 KB.
The outbound guard. Built on the standard library's BlockList, which
already understands IPv6 and prefix arithmetic, so the blocked ranges are a list
that can be read and trusted at a glance. It is applied in the zod schema that
accepts a URL, where the check is synchronous and name-level and produces a clear
400 while the Player is still looking at the form; and again where a transport or
fetch_url is about to connect, where it resolves the name and tests what it
actually points at. The second one does the real work: a row written before the
guard existed is still in the database, and an attacker does not type
169.254.169.254 into a field that rejects it — they point a name they control
at that address and type the name.
Redirects. fetch_url follows them by hand, re-running the guard on each hop
with a ceiling on the number. It previously passed the whole chain to fetch,
which checked the first address and then connected to wherever a 302 pointed.
Failure behaviour
Being reachable and being authoritative are different things, and AgentsVille splits them per call site rather than per package (ADR 0014).
Fails open, because a cache outage must not become an outage: Village settings, the embedding cache, session resolution (better-auth falls through to MySQL, which still holds the session), and the global rate limit — availability protection rather than an authorization boundary, where a 500 on every route at once is the worse failure.
Fails closed, in two places. Sign-up answers 503 when the rate limiter's store cannot be reached, checked directly in the preHandler: registration being briefly unavailable is far cheaper than unlimited Invite Code guesses for the length of an outage. And deleting a session throws rather than swallowing, because reads prefer the cache — a swallowed delete would make signing out appear to work while the session kept working.
The Operator boolean is deliberately never cached, because a cached authorization boundary means someone whose operatorship was just removed keeps it until a TTL expires. And a failed migration crashloops the container rather than reporting partial success, so an instance never serves a schema it does not match.
Still open
The browser-facing Content-Security-Policy is Report-Only. The web
application is server-rendered, so its hydration payload arrives as an inline
script, and a policy without 'unsafe-inline' in script-src yields a blank
page. But 'unsafe-inline' is precisely the permission that makes a CSP weak
against the injected script it exists to stop, so an enforcing policy containing
it would buy very little while looking like it had bought a lot. The honest
description of the web origin's XSS posture today is "React escapes by default
and nothing else is stopping it". The upgrade path is specific — emit a
per-response nonce, put it on every inline tag, drop 'unsafe-inline', and
rename the header — and it is recorded in
ADR 0012. An Operator
who writes their own proxy configuration gets no CSP at all.
The outbound guard has a DNS rebinding window. It resolves a name and the
socket opens afterwards, so a name that answers twice can pass the check and
point elsewhere for the connection. The exposure is one DNS TTL. Closing it needs
a custom DNS lookup at the point the socket is opened, which neither fetch
nor the MCP SDK's transports expose today.
Rotating BETTER_AUTH_SECRET is destructive. Credential encryption derives
its key from that secret, so rotating it signs everyone out and makes every
stored MCP header and Connector token unreadable — one secret to generate, rotate
and back up instead of two, at that cost. See
Rotating secrets.
There is no per-person invite, and no revocation short of deleting someone.
The Invite Code is one shared secret; rotating it is an environment change and a
restart. Operatorship likewise cannot be moved from the interface — it is a row
update by whoever has database access, which is the same person who holds the
.env.
There is no static analysis. Dependency auditing and secret scanning are the two gates that are both free and worth their runtime; a SAST suite can be argued for when a finding justifies it.
Reporting a vulnerability, and the operational side of all of this, are in Self-hosting and Troubleshooting.