skip to content
agentmash get started
docssecurity

trust

security

The room key is the credential. It's an unguessable string that acts like a secret link: anyone holding a live one can read and write that room's events. It lives in the repo alongside the hooks, so repo access is the security boundary — exactly the boundary you already trust for the code itself. A public repository draws no boundary at all, which is why init refuses one and a key found in one opens its room only beside a push sign-in (above).

That's a deliberate trade, and it's what makes teammate setup a git pull. Everything below raises the floor under it without moving it. All of it is opt-in; a team that does nothing gets exactly the v0 behaviour.

Rotation and revocation

A room's id is its first key, not its identity. npx agentmash rotate mints a second one, writes it into .claude/agentmash/config.json, and puts every older key on a countdown (three days by default, --grace-minutes to change it, 0 for "cut it off now").

npx agentmash rotate && git add .claude && git commit -m "Rotate the agentmash room key" && git push

What a teammate does: git pull. Nothing else — no token to paste, no command to run. The old key keeps working through the grace period, so nobody breaks mid-sprint; whoever hasn't pulled when it lapses stops reporting, silently, until they do. npx agentmash revoke ends the grace immediately, which is what you run when a laptop is stolen rather than when a contractor's last day is Friday.

A key on a countdown is a client, not an administrator. Once a key has been rotated away from, it may keep doing exactly what the grace period exists for — report events, run the pre-edit check, read /activity, list keys, read the audit trail, look itself up so doctor can say "pull" — and nothing else. rotate, revoke, settings, token issue and revoke, purge, delete and CLI sign-in all answer 403 room_key_rotated, and the refusal is audited. Without that rule the grace period would be a takeover window: whoever held the leaked key could revoke the brand-new one, and rotation would hand the room to the person it was run to eject.

Rotation is still a race, and only one. Whoever rotates first holds the current key and the other party is put on the countdown, so if you believe a key has leaked, rotate now rather than at the end of the week.

Rotation keeps the room: its events, its dashboard history, its audit trail. Only the string that opens it changes. New keys are stored on the server as hashes, so a database dump no longer hands out working credentials — except for a room's original id, which is also the rooms table's primary key from v0. Rotating once fixes that for good.

Per-developer write tokens (off by default)

By default the server believes whatever developer a client sends. Turn that off per room:

bash
npx agentmash secure any      # every writer must hold a developer token
npx agentmash secure github   # …and it must have been minted by signing in with GitHub
npx agentmash login           # each teammate, once per machine

any means "a token, however minted". Read the caveat below before relying on it.

login rides the GitHub OAuth app the web installer already uses, in a device-code shape: the terminal holds a secret it polls with, the browser carries only a short code, and no credential travels in a URL on that flow.

What the token actually is. A bearer string. It records whose it is — a GitHub login, and the local identity your hooks send, usually your git email — and the server writes events under the identity it records. It is stored per machine, in ~/.agentmash/credentials.json at mode 0600, and never in the repo. That is where the binding stops: nothing ties the string to a device, an address, or a key that stays on your machine. Anything running as your user can read that file — every npx package, every agent you run — and whoever holds the string can write as you, with the server's stamp on it, from anywhere. Treat it as a password for your name in this room, not as proof that a machine is yours. npx agentmash secure lists the tokens a room has issued; POST /rooms/tokens/revoke kills one, which is what to run when a laptop or a home directory is out of your hands.

On a server with no GitHub app configured, login falls back to trust on first use: the first machine to claim a name binds it, and the room key alone can no longer speak as that person afterwards. That proves precedence, not identity, and the CLI says so.

token_mode any is a mislabelling guard, not an identity boundary. Any room-key holder can mint a token for any name nobody has claimed yet — POST /rooms/tokens is authorized by the room key — and can then write as that name, or squat a colleague's name before they enrol. What any buys is that a confused client cannot mislabel itself, and that a name, once claimed, stays claimed. github is the only mode where an identity means something, because the name comes from the OAuth login rather than from the caller.

An event whose developer doesn't match the token presenting it is stored under the identity the token proves, not rejected — a teammate whose git config drifted should show up under their real name rather than silently vanish mid-session. The rewrite is audited.

This costs no round trip. The token is read from a local file and sent as a header on the request the hook was already making. The advisory budget is untouched.

Where a room key can still surface

The key is a bearer credential, so every place it can be copied matters. This is the whole list as of this branch:

PathStatus
x-agentmash-room header on every CLI and hook requestThe intended form. Not logged.
?room= on the dashboard's HTTP and WebSocket requestsUnavoidable — a browser cannot set a header on a WebSocket handshake. Redacted in our request log; a proxy in front of us will log it in full.
/r/<room-key> — the dashboard URLBy design, a secret link. It reaches browser history, bookmarks and any proxy log. The page is served with Referrer-Policy: no-referrer so it does not ride out on the Referer of anything the page links to or loads.
GET /rooms/:id — the key in a pathWas the leak. The CLI called it on every doctor and login run and it was logged verbatim. The CLI now calls GET /rooms/self with the key in a header; the route stays for old clients only.
Our own request log (AGENTMASH_LOG=1)Names a route by its pattern (/rooms/:id) and never the address asked for, so no key, query or token reaches it.
Fastify's default 404 bodyQuoted the request URL back. Replaced with a body that names no path.
Fastify's answer to a path that does not decode (/rooms/<key>%zz)Quoted the path back, key and all. Replaced with a body that names no path.
Error messages and 401 bodiesNone echo the presented key.
.claude/agentmash/config.json in the repoBy design — that is what makes teammate setup a git pull, and it is why repo access is the security boundary.
agentmash join <id|url> on your command lineLands in shell history. Unavoidable for a command that takes a key as an argument.
A reverse proxy or TLS terminator you runSees everything above, and redacts nothing unless you configure it to.

Deletion and retention

Events are pruned after 7 days by default. A room can ask for less (npx agentmash secure --retention-days 1) or more, up to a year, or up to its plan's history where plans are enforced (Plans and seats).

  • npx agentmash purge prompts --yes — drops every prompt event with the symbol names taken from it, blanks every task hint and the task each claim stated, keeping the file-collision data that makes the tool useful.
  • npx agentmash purge events --yes — empties the room of its work: events and their symbol names, claims, how collisions were settled, and the other names signed-in machines reported under, which that work taught the room. The room keeps working.
  • npx agentmash delete --yes — destroys everything the room collected: its events and their symbol names, its prompts and task hints, its file paths, claims and settled collisions, the repository it was connected from, and the developer tokens with the names bound to them. The room stops answering: every route returns 410 room_deleted, live dashboards are dropped, and the room no longer counts against the server's room limit.

What survives a deletion, and who can read it. The room's audit trail does, along with a tombstone row that keeps it addressable. Deleting it in the same breath would mean the credential that performed the act also erased the evidence of it, which is not an audit trail — and the PR this replaced deleted the room's keys in the same transaction, so the one row it did leave behind could not be read by any API path at all.

So GET /audit is the single route a deleted room still answers, and it answers to any key that still opens the room, not only the one that ran the deletion. If someone deletes a room out from under you, your own key — including one inside its rotation grace period — still reads out who did it, from which key fingerprint, and when. The trail carries actions, actor fingerprints, timestamps and short details; it never carried the room's content and never carried a credential.

It is not kept forever, because "delete my data" has to mean something. The trail ages out on AGENTMASH_AUDIT_RETENTION_DAYS (30 by default), and when the last row for a deleted room goes, its tombstone and its keys go with it and nothing at all remains. Shorten that variable if your data policy says so; a self-hoster who needs an immediate, total erase has the SQLite file.

Audit

npx agentmash audit shows what happened to a room: creation, key rotation and revocation, settings changes, tokens issued and revoked, purges, deletions, identity rewrites, administrative attempts refused because the caller's key had been rotated away from, and reads (/activity and dashboard connections) at one row per reader per five minutes. Readers are named by their developer token when they have one, and otherwise by a 12-character fingerprint of the key they used — which is exactly what you want after a leak: this key, from this address, read this room. The trail never contains a credential, and it outlives the events it describes (30 days by default).

The trail outlives the room it describes: agentmash audit is the one command that still answers after agentmash delete. See Deletion and retention.

/check is deliberately not audited. It runs before every edit inside the advisory budget, and a row per request would be larger than the events table it protects. It is also the route that returns other people's file paths and task hints, so a room-key holder can enumerate a whole room through it and leave no trace — worth knowing when you read a trail and it looks quiet.

Reads are recorded at one row per actor per action per five minutes, so the trail answers "was this key reading the room during this window", not "how many times did it read".

Threat model

What this protects against. Someone who never had repo access cannot read or write a room: keys are 128-bit and unguessable. Someone who had access can be cut off — rotate, and their copy stops working at the end of the grace period, during which it can report events but can no longer administer anything. With tokens on, a room key alone no longer lets anyone write as a colleague who has already enrolled; every administrative action, every refused one, and every read of prompt text is attributable after the fact, and that record survives the room's own deletion.

What it does not protect against. Anyone holding the room's current key can still read everything in the room and administer it — rotate it, purge it, delete it. They cannot erase the record of having done so: the audit trail survives deletion and stays readable by every key that opens the room, not only theirs. That is the model, not a bug: the key is the room. A key that has been rotated away from keeps only its client powers for the length of the grace period: it can read the room and report events, but every administrative route refuses it. Task hints (the first ~500 characters of your prompts) are readable by every key holder and by anyone who held one before a purge. The server sees your file paths, branch names and prompt excerpts in the clear; it never sees file contents. When one of the crew made an edit it also sees which one, by its catalog name; any other agent's name, its id and its prompt stay on your machine. A name is matched as written: an agent of your own that you named exactly like one of the crew (probe-qa) is reported as that crew agent, so what leaves is still only ever one of the crew's twenty-two public names. Each edit also carries an opaque id of the checkout it was made in, so a teammate's hook can tell your clone from theirs: an HMAC of the hostname and the folder under a random key kept in ~/.agentmash/checkout-key and never sent, so neither can be read back out of it, and no guess at either can be checked against it. A public repo would publish its room key to the world, so init, join and rotate refuse one unless told --allow-public-key, and a key in a public repository its room is connected to opens the room only beside the sign-in of someone who can push there. A room never connected to any repository is the one the server cannot judge: a key committed to a public repo before this opens it to whoever reads it, so connect the repository, or rotate. Nothing here defends against a malicious server.

What a self-hosting team should do differently. Run your own server from our private Docker image so prompt excerpts never leave your network (self-hosting is on the enterprise plan, with no checkout), and terminate TLS in front of it — every credential in this system is a bearer token, so plaintext HTTP hands the room to anyone on the wire. Set AGENTMASH_RETENTION_DAYS to whatever your data policy actually says. Turn on agentmash secure github if you can, since it is the only mode where an identity means something. Rotate immediately when someone leaves, rather than on a schedule you were going to get to. Remember that the room key appears in the dashboard URL and in ?room= on the dashboard's own requests, so it will show up in reverse-proxy access logs — our own request log redacts every form of it, and a proxy you run will not unless you tell it to. The table in Where a room key can still surface is the full list of places to check.

from README.md, “How security works here”