SECURITY.md
Rama main · réplica de lectura del repositorio original.
# Security
This project runs coding agents as your own user, with no sandbox, and makes them
reachable from a phone through a relay. That combination is the product rather
than an oversight, so this file has two halves: how to report something that is
wrong, and an honest list of the things that are **known and accepted**. Reading
the second half first is the better use of your time — most of what looks like a
vulnerability here is written down below, on purpose, with the reason it was
accepted.
## Reporting
**Report privately rather than in a public issue**, for the ordinary reason: a
report is more useful before it becomes instructions somebody else can follow
against other people's machines. This is one person rather than a team with a
rotation, so the window between a public issue and a fix is measured in whatever
time that person happens to have.
> **Report it through GitHub, privately:**
> [**Security → Report a vulnerability**](https://github.com/rends-east/reemoat/security/advisories/new).
There is deliberately **no email address here.** A GitHub advisory opens a private
thread attached to the repository itself, which is where the fix has to happen
anyway: the patch is developed in a private fork off that thread and published
with the advisory in one act, rather than travelling as a diff through somebody's
inbox. It also means there is no address to rotate, filter or lose, and nothing
for a scraper to collect off this file.
Please include what you did, what happened, and which of the three parts it
touches — the daemon (`src/`, on your own machine), the control plane
(`packages/control-plane`, which holds the fleet's signing key), or the web UI
(`packages/web`). A reproduction against the drivers (`pnpm daemoncheck`,
`pnpm relaycheck`, `pnpm authcheck`) is the most useful form a report can take,
because that is where a fix will end up being asserted.
A CVE is available where one is warranted — GitHub is a CVE Numbering Authority,
so an advisory raised through the link above can request an identifier without
anybody leaving this repository. That is the whole of the process: there is **no
bounty and no response-time commitment.** Saying otherwise would be inventing a
promise nobody has made.
## Scope
**In scope**, roughly: anything that lets somebody reach a machine they hold no
grant on; anything that gets a plugin's code, or anything it returns, *executing*
in the browser rather than being drawn as data; anything that lets a token minted for one machine verify at another;
anything that lets a caller past the control plane's password, session, throttle
or grant checks; a way to make the relay parse, log or leak what it carries; a
credential written somewhere it should not be (a log line, an image layer, a
transcript, a mail body); and any place a daemon's own containment checks can be
walked out of by a path, a symlink or an upload name.
**Out of scope**, because they are the design and are described below: an agent
reading or writing anything your user can; an agent pushing to a remote with your
credentials; a git hook running during a checkout; anything that follows from
somebody legitimately holding a grant on your machine; anything that follows
from `~/.claude/settings.json` already answering a question the permission
machinery would have asked; and anything a plugin does with the authority it was
installed with, since a plugin is code somebody chose to run on their own
machine.
**Also out of scope, and said here so it is not anybody's first report: there is a
private key committed to this repository on purpose.** `scripts/relaycheck.ts`
carries a self-signed `CN=localhost` TLS key, because the mail half is driven
against a fake SMTP server that has to present something. It expires in 2126, it
signs nothing, nothing trusts it, and the only thing that ever offers it is a
listener bound to 127.0.0.1 by the process checking it. Generating one per run
would need an X.509 library this tree does not have; shelling out to `openssl`
would make the driver skip itself wherever that binary is absent, which is a
driver that is green because it did nothing. It is declared in `.gitleaks.toml`
by its key material rather than by its filename, so a real credential in that same
file still fires — that narrowness is measured, not assumed.
## Supported versions
The latest release and `main`. There is no long-term-support branch and no
backport policy, for the same reason there is no response-time commitment below:
one person cannot staff either, and publishing a table implying otherwise would be
inventing a promise nobody has made.
Fixes land on `main` and go out in the next release. If you are running an older
tag, the upgrade *is* the fix.
## Known and accepted
Each of these was a measurement before it was a position, and each is written up
at length in `docs/DECISIONS.md` — group **Q7** is where the open ones live. What
follows is the summary, not the argument.
**There is no sandbox, and the agent runs as you.** It is a child of the daemon
process, with your uid, your `HOME`, your files, your `~/.ssh`, your browser
profile and your other repositories. `cwd` is not confined, `REEMOAT_ROOTS`
narrows the directory *picker* and nothing else, and the ACP `fs` capabilities are
granted because declining them would confine nothing — the agent could make the
same read itself. This is the trade every coding agent on a laptop already makes.
What this daemon adds is that it can be driven from a phone, over a relay, by
anybody holding a grant on the machine. The seam for a sandbox, if one is ever
wanted, is `SessionRuntime`.
**A plugin runs as you, and it is somebody else's code.** It arrives as a file
whoever owns the machine chose, and it runs as a child process of the daemon with
your uid, your `HOME`, your files and your keys — the same trade the agent already
makes, through a different door. The scope list in its manifest is declared, shown
at install and refused when exceeded, and it is **hygiene rather than a fence**:
the child can `import("node:fs")` and read everything the daemon can. What it does
buy is that the blast radius is named before somebody consents to it, that a
plugin which hangs or crashes cannot take the daemon's single event loop with it,
and that a plugin never holds the daemon's token or its database handle.
**"Before" is load-bearing, and it is why the manifest is read by whoever is
installing rather than by the machine.** The archive is not sent until its scopes,
the hosts it named and the events it asks to be told about have been drawn and
agreed to — in the browser by `packages/web/src/pluginArchive.ts`, and at a
terminal by `pnpm client plugin install`, which prints the same list and waits.
Neither is a validator: the daemon still refuses authoritatively on arrival. They
exist because the alternative was what this used to do — unpack the archive, write
the row, start the plugin, and *then* show somebody the scopes of something already
running, on a screen whose own copy told them to read it first. A disclosure after
the fact is not consent. Where the archive cannot be read locally at all, that is
said plainly and the way through is a separate, named press; it is never guessed at.
**What is a real boundary is that the browser executes none of it.** A plugin
returns a *description* of a screen and the web client draws it with its own
components, so the origin holding `reemoat.credential` runs nothing a plugin
author wrote. There is no plugin bundle, no sandboxed frame and no `postMessage`
bridge, because there is nothing of theirs to run.
`net.fetch` is a **tap rather than a fence**, for the reason everything else here
is: the daemon makes the request, against the host names the manifest listed, over
https, following no redirects — but a name somebody controls can resolve to a
private address, and the plugin could open its own socket regardless. It exists so
that a plugin which stays inside the API is auditable, not so that one which
leaves it is stopped.
Nothing downloads a plugin, nothing updates one by itself, and there is no
registry. Install one you would run in your own terminal.
**The environment strip is hygiene, not a fence.** `agentEnv()` removes
`REEMOAT_*` and the session-scoped `CLAUDE_*` names, and an agent running as this
uid can still read `/proc/<pid>/environ`, the env file and the database. What the
strip prevents is three accidents, not an attacker.
**The installer is `curl | sh`, and that is code execution by design.** `GET
/install.sh` serves a shell script people pipe into a shell, so whoever can serve
that path on that origin runs code as the person installing. What bounds it:
TLS to an origin the person chose and already trusts with their sign-in; the
script is served as `text/plain` so it renders in a browser rather than
downloading, which is what makes "read it first" advice somebody can take;
nothing in it runs as root or asks for a password; and everything it writes is
under `~/.reemoat` and the checkout it is told to make. Its one caller-influenced
input — the origin, which comes from the request's own `Host` header — is
single-quoted through `shellQuote`, and `imagecheck` sends a hostile `Host`
through a real container to prove the quoting is on the path a request takes
rather than merely present in three files.
What does **not** bound it, said plainly rather than argued away: **nothing pins
the script's content.** There is no signature and no checksum a first-time
installer could check it against, because the only thing they would check it with
is the thing being installed. `services/premium`'s cloud-init provisioner has a
digest because cloud-init verifies it out of band; a one-liner has no equivalent,
and inventing one that the same origin serves would be theatre. The honest
statement is that trusting the control plane's origin is a precondition of using
this at all — it is where the browser client comes from, and it mints every token
the fleet verifies.
**Git hooks run, deliberately.** `GIT_NO_EXEC_CONFIG` is deleted, so
`git worktree add` runs the repository's own `post-checkout` and its LFS smudge
filters, and an agent pushes with your `~/.gitconfig`, your credential helper and
your keys. Cloning a hostile repository is exactly as dangerous here as in your
own terminal, and no more. Neutralising it was tried and cost a silent failure:
a blanked `GIT_CONFIG_GLOBAL` checks out LFS pointer files instead of content.
**`~/.claude/settings.json` can bypass the permission machinery entirely.** Where
it blanket-allows `Bash`, `Edit` or `Write`, the inner CLI decides for itself and
the daemon's permission state machine never sees a request — so a switch in the UI
saying "ask me every time" would be a lie next to a config that already answered.
The settings screen reads that file and says so. Testing the permission path needs
`kimi`, or an isolated `CLAUDE_CONFIG_DIR`.
**The control plane's database is the whole fleet.** It holds the Ed25519 private
key that signs every token for every machine. A leak of that file is a total
compromise: whoever has it can mint a token for any machine and any grant. There
is no per-machine revocation short of re-enrolling every host by hand —
`cpctl admin rotatekey` mints a new key and `retirekey` drops the old one, but a
daemon **captures the key set once at enrollment and never asks again**, so the
old key keeps verifying at the edge until each machine has been visited. Rotation
is the remedy for a leaked database; the visit to every machine is the part it
does not remove. `deploy/backup.sh` snapshots that file, which is also to say that
a backup of it is the same secret again.
**An admin credential no longer reaches a machine on its own, and that is a much
smaller claim than it sounds.** Three routes were the whole of it and are gone or
narrowed: `PUT`/`DELETE /v1/admin/grants` (deleted — sharing is the owner's `PUT
/v1/machines/:id/grants`), `PUT /v1/admin/machines/:id/owner` (refuses a transfer
away from a live owner, and refuses adopting an ownerless machine somebody holds a
grant on unless they are the one being handed it — so it adopts rows nobody
depends on and re-labels for the owner a machine already has), `POST
/v1/admin/machines/:id/enrollments` (refuses a machine that is enrolled and has an
owner *or grantees*, because redeeming a code retires the running daemon's tunnel
key and so replaces the machine rather than reading it). `INSERT INTO grants`
appears in `app.ts` twice and in `machines.ts` once, and no route under
`/v1/admin` adds or widens a grant, on a machine that already has an owner, for
anybody other than that owner.
That string is quoted without `db.prepare(` in front of it on purpose, because an
invariant nobody can check by hand is not one: both `app.ts` writers break the line
after `db.prepare(`, so the longer form greps to **one** of the three and reads as
though the other two do not exist. `relaycheck` matches
`/db\.prepare\(\s*"INSERT INTO grants/` for that reason and says so.
⚠ **Read "ownerless" as "nobody depends on it", which is the correction rather
than the claim.** Both guards were written keyed on ownership alone, which quietly
reads *no owner* as *no users*: a machine registered before ownership existed can
be enrolled, online and carrying other people's grants, and an admin could adopt
it with every scope or re-enroll it out from under those people, both answering
200. They key on owner-or-grantee now. A genuinely orphan row — enrolled, owned by
nobody, granted to nobody — has nobody to ask and stays the operator's, which is
the only case they were ever justified by.
**Read that as narrowly as it is written: it is a statement about the HTTP
surface and about admin *credentials*, and it is not a boundary.** Whoever
operates this control plane still holds `signing_keys.private_pem` and can sign a
token for any machine with any `sub` — the daemon checks the signature, the issuer
and the audience, and never compares the subject to anything — so the operator's
reach is unchanged and no route deletion can change it. They also serve the web
client from their own image, terminate TLS at the relay, and hold the database.
(⚠ Terminating TLS there no longer means *reading* what passes through it — see
the end-to-end encryption note below — but the other two clauses are untouched,
and serving the client is the one that matters most: whoever ships the code that
holds the keys does not need to read the wire.)
What the deletions buy is that an admin account, on its own, is no longer one
request from somebody else's computer; **an operator is still trusted completely,
and self-hosting is the only version of "not trusted" this system has.**
**Machine substitution is open, and is disclosed rather than refused.** An admin
can revoke your machine, register a new one for you under the name that frees, and
enroll it on their own hardware — your list then draws the name you lost, owned
and online, and it is their computer. Every step has to stay: revoking is the
denial side, and registering a machine for somebody is what `install.sh`'s wizard
does. Both obvious refusals restore bugs already fixed. So `GET /v1/machines`
carries **`enrolledBy`**, which names whose enrollment code a machine enrolled with
when that was not yours — drawn on the machine row in the web UI and printed
by `cpctl machines`, because a disclosure only a `curl` reader sees is not one.
It is read off `machines.enrolled_by`, written at the redemption it describes.
Derived from `enrollment_codes` instead — which is what shipped first — it was
wrong four ways and **every one of them answered `null`, which the route reports
as "you enrolled this yourself"**: that table is swept seven days after a code is
used; `created_by` is deliberately left dangling when an account is deleted, so an
inner join dropped the row with the account; `POST /v1/provision` writes a
provisioning key's id that matches no user at all; and `used_at` is stamped by
four *burn* paths as well as by redemption, so the owner pressing "new enrollment
code" twice — the likeliest reaction to noticing something odd — overwrote the
name with their own. A column written once, at the moment that knows, has none of
those states.
⚠ **A name is not by itself a substitution, and that is this field's real limit.**
`install.sh`'s daemon wizard registers the machine and enrolls it on an admin's
code, so *every* wizard-installed machine names the admin who ran the installer,
and a substitution draws the same row as a normal install. What the field buys is
that you can tell *this enrolled with somebody else's code* from *with mine*, and
recognise the name or not. The remedy for a name you do not recognise is to
re-enroll the machine yourself, which sets it back to you.
⚠ **Three further things it does not cover, stated because a disclosure nobody has
bounded is trusted past what it says.**
It names who **minted** the code, never who **redeemed** it. `POST /v1/enroll` is
public — the credential *is* the body, and a daemon presents no account — so there
is no redeemer to record; `used_from` keeps the address and is forensic rather than
shown. A code **you** minted that leaks and is redeemed on somebody else's hardware
therefore reports you, which draws nothing. Minting again supersedes the old code,
which is the remedy for that shape; this field is not evidence about it.
It is **not retroactive.** The column is written at redemption, so every machine
that enrolled before it shipped has no value — which on the day of the upgrade is
all of them. Those rows say *somebody this control plane did not record* rather
than nothing, because folding them into the reassuring answer is the exact failure
the column was built to end. There is no backfill: the table it would read from is
swept seven days after a code is used, and the four defects below apply to a
backfill identically.
And it moves when the machine list is **read**, not on the four-second poll — a
wake, or a reload. `packages/web/src/machine.ts` says so at the fold.
**A daemon makes exactly one control-plane request, ever** — the enrollment
exchange. That is what makes a control-plane outage cost reachability rather than
work in flight, and it is the same property that makes revocation slow: nothing is
re-checked, no revocation list is fetched, and a grant revoked at the control
plane stops new requests at the *relay* rather than at the daemon.
**Tokens are not replay-tracked, and they are bound to a device.** They live
300 s with 60 s of clock leeway either side, verification is local and stateless,
and nothing remembers a `jti`. What changed is that a leaked one is no longer
*bearer*: every capability names the requesting device's X25519 public key in an
RFC 7800 `cnf.jkt` claim, and the daemon refuses one whose key is not the key the
encrypted handshake just authenticated. So a capability out of a log or a proxy
cannot be spent from another machine — the holder would have to produce a private
key that never left the operating system's keyring on the device it was minted
for.
Two honest limits on that. A capability carrying **no** `cnf` at all is refused
rather than treated as unbound, so the binding cannot be opted out of — but it is
enforced by the *daemon*, on the encrypted path, so a stolen capability spent
against a daemon on the same computer over loopback is still a bearer token for
its remaining life. And `?token=` still exists on exactly one hop: the app's
WebSocket handshake to the relay, because a browser cannot set a header on one.
It no longer appears on the daemon's own loopback dial, which is made by Node and
carries a header.
**Traffic through the relay is end-to-end encrypted, and that is the only mode.**
The app and the daemon run `Noise_IK_25519_ChaChaPoly_BLAKE2s` between themselves:
the app's static is its device key, the daemon's is a machine key it generates on
first start and announces on its tunnel dial, and the Authority reports that key
on the same call that says where the machine is. The relay authorizes the
connection and then moves bytes it holds no key for — prompts, diffs, file
contents and terminal output are ciphertext to it. There is no plaintext mode to
negotiate: the constant naming one was deleted, `RELAY_PROTOCOL_MIN_VERSION` was
raised past every build that spoke it, and a daemon that cannot speak the
encrypted mode is reported unreachable rather than reached another way.
⚠ **What this does and does not buy.** It removes the **relay** from the trusted
payload path — a compromised carrier, a hostile TLS terminator or anyone reading
that host's memory gets ciphertext. It does **not** defend against a malicious
Authority: that service mints every capability and holds
`signing_keys.private_pem`, so it can issue one naming a device key of its
choosing and talk to your daemon as you. It does not make the operator untrusted;
they still serve the client from their own image. E2EE narrows one party's reach,
and the paragraph above about operators is unchanged.
**A relayed stream's authorization is checked at open and not re-checked.** A
grant revoked mid-stream does not tear down a live WebSocket; the daemon's own
expiry re-check on the ping tick closes it when the token dies. Every ordinary
request is refused immediately.
**Retiring a device is a control-plane act, and it does not reach a token already
minted.** A *device* is the installation somebody signs in from — the computer or
the phone — and retiring one ends every session bound to it and refuses any future
sign-in that offers its id. What it deliberately does **not** do is enter the
token-verifying half of the system: `relay/authorize.ts` reads users, machines and
grants live and reads no device row, because permissions belong to the **person**
and a device authenticates as one. So per-device revocation is a grouping key over
sessions plus a bind refusal, not a new boundary, and the windows are the ones
above:
| Path | When a retired device stops |
|---|---|
| Any control-plane request | next request |
| Minting a machine token | next request |
| A machine token already minted, **over the relay** | ≤ 300 s + 60 s leeway, from the last mint — and only from *that* device, because the capability names its key |
| A WebSocket already open | that, plus one 20 s ping tick |
| Loopback to a local daemon | the same ≤ 360 s, with no control-plane hop at all — and here the capability **is** a bearer token, because a loopback request has no channel to be bound to |
⚠ **Two rows changed with end-to-end encryption and the rest did not.** Device
retirement still bites at the *next mint* and nowhere deeper — nothing in the
token-verifying half reads a device row, and it must not learn to. What the key
adds is that the window on an already-minted capability is now a window **for one
device**: it cannot be spent from anywhere else over the relay, because the daemon
compares the key inside it against the key the handshake authenticated. The
loopback row is the exception and is stated rather than glossed: there is no
channel there, so there is no key to compare, so a capability on that path is what
it always was for its remaining life. That is the same trade `.claude/rules/relay.md`
already bounds, taken by a process running as the uid that owns `~/.reemoat`.
⚠ **And a device id is not a credential.** It is an identifier this service hands
back, stored unhashed and returned in full: holding one authorizes nothing,
because every request still carries the session token and the id is read only
*after* that token has resolved. That is why the client keeps it in ordinary
configuration rather than an OS keyring — which also means it is **not** protected
at rest on the client, and does not need to be.
⚠ **A caller on an API key has no device**, so nothing that came in on one appears
in a device list and nothing there revokes one. An API key is retired by its
holder, from the keys screen or `cpctl keys --revoke`, and by nobody else — an
admin has no verb over anybody's keys (Q1.631). The Devices screen says so rather
than implying it is the complete inventory of what can reach an account.
**Registration is a user-enumeration oracle, knowingly** (Q7.78). A taken name
answers `409`, because a name is the login and a form nobody can complete is not a
form. What bounds it: every branch of the route costs the same scrypt, so it is
not *also* a timing oracle; each probe takes one of two fleet-wide public-lane
slots; and a probing host is blocked after five. What is deliberately not
conceded: an **address** is not something the person at the keyboard chooses, so a
taken one answers the same `200` as a fresh one, and `POST /v1/forgot` answers
byte-identically for known, unknown and unverified.
**The body-cancel discipline under the middlewares above a streaming route is
now enforced, and measured in one process rather than through a relay** (Q7.62).
The three streaming routes reason carefully about cancelling a body they refuse,
but the auth gate and the scope check sit *above* them, so a 401 or a 403 used to
answer without releasing the upload the client was still sending. The obligation
now hangs off the same guard that grants the exemption from the ordinary body
bound, so every answer produced below that line releases the body — measured
against all three routes, with a stream that records whether anybody cancelled it.
What is still not established is the half that needs a relay: whether an
unreleased body really does park a sender and trip the tunnel valve, and how much
of one it takes. Two gaps remain in the guard itself, both stated rather than
fixed: `cors()` answers an OPTIONS preflight *above* it, and a handler that took a
`getReader()` and abandoned it would leave the stream locked, which `cancelBody`
swallows. Neither is reachable today — a preflight carries no body worth parking,
and every streaming handler reads with `for await`.
**The first admin's password and API key are printed to the container log**, and
that is the contract rather than an accident: `main.ts` writes them on the one
start that bootstraps an admin, and `deploy/install.sh` gets them by scraping the
log, because there is nowhere else a fresh container can hand them over. What
follows is that both live in the Docker log driver's history — which is not the
database, is not covered by `deploy/backup.sh`, and outlives the sign-in that used
them. **Rotate after the first sign-in, or avoid the print**: setting
`REEMOAT_CP_BOOTSTRAP_ADMIN_PASSWORD` makes that line name the variable instead of
the value. The API key has no such door — it is minted with `cpctl key` and
retired with `cpctl keys --revoke <keyId>`, so rotating it is two commands and the
old one stops working.
**The mail outbox holds rendered messages, including live one-time links**, until
it is swept.
**What the control plane keeps about people**, so that it is stated rather than
inferred from the schema: a login name, a password hash, an optional email
address, and — per sign-in — the IP address and `User-Agent` the session arrived
with (`user_session_origins`), which are recorded for recognition and are never
used to authorize anything. **And now a name per registered device**: what
somebody calls the computer or phone they signed in from, plus the platform it
reported. Both are caller-supplied and clamped at ingest, neither authorizes
anything, and the name is usually a host name — which on a personal machine is
frequently a person's own. It is listed to its owner, it is retired by them, and
`DELETE /v1/admin/users/:id` sweeps every device row of a deleted account by hand,
because nothing in this database cascades. Sessions and their origins are swept 7
days after revocation; a retired device is swept after 30 — longer because that
list is read *after* something has gone wrong rather than as a live inventory, and
a list one row shorter cannot say whether a laptop was retired or never registered.
Email tokens and unconfirmed sign-ups are swept on expiry.
`enrollment_codes` is swept 7 days after a code is used or expires, whichever
applies — `used_from` is the only forensic trail here, so a code is not dropped on
the tick of expiry.
⚠ **`machines.enrolled_by` is the one fact here that outlives every sweep and
every deletion, and it names a person.** It holds whoever minted the code a
machine enrolled with, and it is deliberately not cascaded: deleting an account
leaves the id behind, and the machine's owner is shown "a deleted account" rather
than the reassuring nothing an inner join gave. That is the point of it — the
disclosure above is worthless if the person it discloses can erase it — but it is
a record about somebody that survives them asking to be forgotten, so it is
written down here rather than left to the schema. It is scoped: only somebody who
can already see the machine can read it, and it is a display name rather than an
id or an address.
There is **no access log**:
nothing writes a row per request or per relay CONNECT, so there is no record of
who reached which machine when.
## What this file is not
It is not a threat model for a multi-tenant service, because this is not one. One
person, one machine, many agents, and the machine is yours. If that sentence is
not true of your deployment, the list above is the list of things you are relying
on somebody else not to do.