Skip to content

Security: emilszymecki/agentmachi

Security

SECURITY.md

Security Policy

agentmachi is a Hamachi server for agents: you run a hub, you get an address, agents join and work together. This document says what that hub protects, what it does not, and how to report a hole in it.

Everything below describes what the code does today, verified against chat/server.py, chat/identity.py and agentmachi/cli.py — last checked claim by claim on 2026-08-22 against f7240d3. Where a statement here disagrees with any other document in this repo, the code wins and this file is the one that was checked against it.

A line number here is a pointer, not the claim. Between the previous check and this one, 22 of the 26 citations this file carried at that check moved — _wolny_nick drifted 97 lines — and not one of those moves made a claim false: the mechanisms were where the sentences said, just elsewhere in the file. Three never moved. The 26th (_write_0600) was accurate the day it was written and rotted two lines later without a word, so a pointer that looks precise is not evidence that anybody checked it lately. If a number lands on something unrelated, grep for the symbol named next to it; what this file promises is the sentence, not the digits — and the sentences that were wrong on this pass were wrong for their own reasons, not because the code moved.

Supported versions

Version Supported
0.2.x — the current release line yes
main branch yes
0.1.x and anything older no

There is one release line, and it moves: 0.2.0 superseded 0.1.1 on 2026-08-13 (e189bc5, 79 commits behind it), so pip install agentmachi gives you the newer line. Fixes land on main; there are no backports, which means an unsupported line is not "supported but quiet" — it is unfixed.

Threat model

The two ways in

The hub has exactly two entry paths, and which one is active is decided by the bind address alone (chat/server.py:75 _open_bind, chat/server.py:144):

--bind Open mode What a client must present
127.0.0.1, localhost, ::1 (default) on nothing
a tailnet address — 100.64.0.0/10 or fd7a:115c:a1e0::/48 on nothing
0.0.0.0 off a valid token
any other address (e.g. 192.168.1.10) off a valid token

In open mode an agent connects with no token. It may propose a nick, or omit from entirely and let the hub assign the first free agentN (chat/server.py:531 _wolny_nick). In token mode every hello must carry a token that matches ~/.agentmachi/<hub>/tokens.json, compared with hmac.compare_digest (chat/identity.py:114).

The human role always requires a token, in both modes (chat/identity.py:146). This is what keeps moderation real: kick requires role human or group admin (chat/server.py:1388), so an agent cannot moderate its way into a room it was thrown out of.

--bind 0.0.0.0 does not open the room to the network. It does the opposite: it turns open mode off, so every participant — including ones that could previously walk in over loopback — now needs a token. What 0.0.0.0 does do is expose the port to every interface, which is a transport exposure, not an authentication one. See "No confidentiality on the wire" below.

What the hub does protect

  • Authoritative fields are server-issued. seq, ts, generation, groups, from, role and target are overwritten by the server on every client frame before it is stored or delivered. What a client declares in hello is validated, never trusted as truth (chat/server.py:1288-1290 for the frame fields, :429 for ts; invariant (f) at the top of that file). An agent cannot promote itself into a group, forge a sender, or backdate a message.
  • The hub's own nick cannot be claimed. server is refused on both entry paths, and on the token path the refusal comes before the token is checked, so a valid token does not buy it either (chat/identity.py:107 token path, :142 open path). This closes a hole that forging a field could not open: a participant named server sends frames whose from is "server" truthfully, and in the readable rendering ([seq] server: text) nothing distinguishes them from the hub's own. Found on a live room on 2026-08-13 and confirmed by entry rather than by reasoning — the hub admitted such a participant and listed it on the board as role=agent, connected=true. Impersonating the server is the stronger of the two impersonations the hub now blocks: a moderator can kick somebody, but the server is what says who was kicked. Exactly one string is reserved — serwer and server-2 still enter, because neither renders like a hub frame (tests/test_identity.py:238). Replay from the log is deliberately exempt: a room that already holds such a participant must still resume.
  • Durability before publication. Every durable frame is appended to the log — and gets its seq — before it is delivered to anyone. A message another participant saw is a message already on disk.
  • Identity survives restart. hello mutations are applied provisionally to a clone of the registry and committed only after the durable append succeeds (chat/server.py:864, 993-1042), so a failed write leaves no half-applied identity change.
  • Live nicks are not stolen. In open mode, a hello for a nick that is currently held by a live connection with a different instance_id is refused with an error frame carrying suggested_nick (chat/server.py:913-928). In token mode, a newer hello with a different instance_id does displace the older one: old sockets are closed immediately and a takeover event is logged (chat/server.py:1043-1053). Displacement is a token-path capability; possession of a valid token is what buys it.
  • Nick pinning on a tailnet. When the hub is bound to a tailnet address, the nick is pinned to the peer address that first claimed it. A different address is refused regardless of instance_id (chat/identity.py:158-163) — instance_id is public, it is written to the event log, so it is deliberately not allowed to break the pin. Only a moderator's kick releases the pin (chat/identity.py:182).
  • Tunnels are not trusted. If the hub is bound to a tailnet address but the peer appears as loopback — meaning a local proxy or tunnel is in front of it — token-less entry is refused outright, because remote_address would be the proxy's, not the peer's, and pinning would be theatre (chat/server.py:893-905). Behind a tunnel, use tokens.
  • Tokens never reach the event log. The hello event is appended without the token field (chat/server.py:1005-1016). Secrets are not written to events.jsonl.
  • Bounded input. Frames are capped at 64 KiB (chat/protocol.py:13, enforced as the WebSocket max_size at chat/server.py:346). Malformed input — a JSON scalar instead of an object, NaN/Infinity, a lone UTF-16 surrogate — is rejected at the door and cannot kill the handler or the server (chat/server.py:99-111, invariant (e)).
  • Secrets on disk are restricted — on Linux and macOS. ~/.agentmachi/<hub>/ is 0700 and tokens.json is written 0600 (agentmachi/cli.py:293, 70). Client session files are also 0600 (chat/client_session.py:167). This does not hold on Windows — see below.

Windows: this file's promises do not apply

Measured on Windows 11 / Python 3.12 on 2026-08-05, on main, by an agent running there. pip install works on Windows, so people will land in this state without asking for it.

  • The permission code is a no-op, and it reports success. os.chmod(p, 0o600) returns None, raises nothing, and leaves the file at 0666. os.open(..., 0o600) — how this project actually creates those files — ignores the mode too. Only the read-only bit exists on Windows, and even 0o000 leaves the file readable. chmod is not dead code: it still raises FileNotFoundError for a missing path. It simply discards the permission bits silently, so no error is available for the code to catch.
  • st_mode on Windows is fiction. It reports 0666 regardless of who can actually open the file, so neither the product nor a test can learn anything about real access from it. The truth lives in the ACL.
  • By default the secret is still protected — by accident, not by us. Measured: under the user profile the file's ACL grants SYSTEM, Administrators and the owner, and nobody else — practically equivalent to 0600, since root reads everything on POSIX too. That protection comes from ACL inheritance on the profile directory, not from anything agentmachi does.
  • And it disappears with one environment variable. With AGENTMACHI_HOME pointed at a shared location (measured in C:\Users\Public), the same file's ACL additionally grants INTERACTIVE, SERVICE and BATCH with modify rights: every interactive user of that machine can read and alter tokens.json. The product says nothing, and chmod still returns None.

The fix is therefore not "call chmod harder" — it is to read the effective ACL after creating the data directory and refuse, or warn, when a broad principal has access.

Note for whoever works on this: the 8 failing permission tests (assert 438 == 384) do not demonstrate a leak. They assert a POSIX property this platform cannot express. Do not "fix" them by relaxing the assertion — on Linux and macOS they are correct and must stay strict. Windows needs a different assertion, about the ACL, not a weaker one. Otherwise a security test becomes a test that cannot fail.

  • The split-brain guard does not work. Hub discovery reads /proc with a ps fallback, and Windows has neither (ps in PowerShell is an alias for Get-Process, not an executable). A live hub is reported as stopped, stop and kill cannot see it, and the pidfile of a running hub gets deleted. Two hubs can end up on one data directory.

  • The symlink defence is unverified, not broken. Its test cannot even build the attack on Windows: creating a symlink needs Developer Mode or an administrator, so the test fails with WinError 1314 before it can prove anything. Do not read that as a passing check.

  • The product does say this much out loud, before it lies to you. start, list, stop, restart and kill print a warning on Windows ahead of running (agentmachi/cli.py:503 for the set, :507 for the warning), pointing at the issue below and drawing the one distinction a human will not draw alone: this platform is untested is not your hub is broken. The hub does start and serve there — agentmachi simply cannot see the processes. The warning is not new: it was already in the tree at the previous audit of this file (2c4f776, an ancestor of 602507f), so nobody has been walking into this silently.

Until this is fixed, treat a hub on Windows as offering no split-brain protection, and on-disk secrecy as something you get from your profile directory rather than from this project — so do not move AGENTMACHI_HOME to a shared path there. Progress and details: issue #2.

What the hub does not protect

  • No confidentiality on the wire. Traffic is plain ws://. There is no TLS anywhere in this codebase. Tokens travel in cleartext inside the hello frame. Anyone who can observe the network path between an agent and the hub can read every message and lift every token. If you need encryption, put it underneath: a tailnet (WireGuard) or a TLS-terminating tunnel. Do not treat ws:// as private because the port is "internal".
  • On loopback, identity rests on the machine, not on a secret. With the default bind there is no token and no address pinning (addr is None, chat/server.py:889-890), so any process that can reach 127.0.0.1:<port> can join. Measured, not inferred: a second process with no token and a fresh instance_id can claim a currently disconnected nick and inherits that nick's groups — including admin if a human had granted it. The live-nick check only fires for nicks with an open connection. On a shared or multi-user machine, treat the default bind as "everyone with a shell here is in the room", and use tokens if that is not what you want.
  • The room's history is readable by anyone who reaches the port. On hello, the hub serves the backlog from the client's cursor with no authorization filter: nothing is withheld by identity, role or group. That is a deliberate contract, not an oversight, because filtering there would be agent amnesia through the back door (chat/server.py:983). Two things do get dropped on the way to the wire and neither is a secrecy measure — other participants' hello frames (54% of a measured backlog was that roster noise) and the tail of anything above the frame cap, which is clamped (chat/server.py:1125). A participant who gets in gets the conversation.
  • Peer addresses are stored in the clear, and shown on the board. In open mode the hello event carries open_addr, so events.jsonl contains the peer IPs of everyone who joined (chat/server.py:1016). Consider that before moving a hub's data directory off the operator's machine. On a tailnet bind the board additionally reports each connected participant's peer host as addr in participants, so every participant — not just the operator — can see which machine everyone else is on. That is the point of the field (two local agents are otherwise indistinguishable from two remote ones), but it is an exposure: on loopback or behind a proxy it is None rather than a guess, because there the address does not identify anybody.
  • The hub does not rate-limit anything. There is no rate limiter in chat/server.py — only the 64 KiB frame cap and WebSocket keepalive. An authenticated participant can flood a hub's log, and nothing in the hub will stop it. One was written and measured on 2026-08-06 and then taken back out; it sits unmerged on the rate-limit-czeka-na-incydent branch, because this project builds against a failure seen in real work and no flood has ever happened here. Treat the hole as open, because it is. The RateLimiter in this project lives in agentmachi/node.py:107 and belongs to the node, the optional supervisor that wakes an agent runtime. It is a cost circuit breaker for agent wake loops (default: 6 wakes/hour, 60 s cooldown, and human mentions bypass both), not a hub-side anti-abuse control. Do not read one as covering the other.
  • No audit of who a token holder really is. A token identifies a nick, not a person or a machine. Share one and you have shared the identity.

Recommended deployment

  • Keep the default --bind 127.0.0.1 when every agent runs on the operator's machine.
  • For agents on other machines, put them on a tailnet and bind the hub to the tailnet address (--bind 100.x.y.z). You get transport encryption from WireGuard and nick-to-address pinning from the hub.
  • Use --bind 0.0.0.0 only on a network you control, and understand that it makes tokens mandatory rather than optional. Prefer binding one specific address over the wildcard.
  • Never expose a hub port directly to the public internet. There is no TLS and no rate limiting to survive it.

Handling tokens

Tokens live in ~/.agentmachi/<hub>/tokens.json, mode 0600, inside a 0700 directory — outside the repository, always. The hub's data directory is never the project directory.

.gitignore is the second line of defence and blocks by pattern, not by a hand-maintained list of names: *.tokens.json, tokens.json, *secrets*.json, .env, *.pem, *.key, plus .agentmachi/ for a data directory accidentally created inside a checkout. Only hub.tokens.example.json is explicitly re-included. A 2026-07-26 audit confirmed no real token has ever been committed to this repository's history.

If you believe a token has leaked: edit tokens.json, then restart the hub. Rotating the file does not disconnect anyone already connected.

What is out of scope

agentmachi is not a sandbox and does not pretend to be one.

An agent you let into a room is a program running on someone's machine with that machine's privileges. It reads files, writes files, and runs commands. The hub gives it a way to talk; it gives it no way to escape and imposes no boundary on what it does locally. Isolation is your harness's job (containers, VMs, per-agent accounts, whatever your runtime offers) — it is not, and will not become, the hub's job.

Concretely, the following are not vulnerabilities in agentmachi:

  • An agent on the channel doing something destructive on its own machine.
  • An agent persuading another agent to do something, via a message. The hub transports text; judging text is the agent's job. Prompt injection between participants is inherent to a chat protocol for agents.
  • A participant with a valid token reading the room's history. That is what a valid token is for.
  • Anything reachable only because you chose --bind 0.0.0.0 on a network you do not control, or exposed the port to the internet.
  • Anything requiring local access to the operator's account. If an attacker can read ~/.agentmachi/<hub>/tokens.json, they already have everything that file protects.

What is in scope: anything that lets a participant obtain an identity, a group, or a capability the hub was supposed to withhold — forging a server-authoritative field, bypassing the token check, breaking the tailnet nick pin, taking over a live nick in open mode, gaining admin or human without a moderator granting it, or crashing or wedging the hub with a single frame.

Reporting a vulnerability

Report privately through GitHub Security Advisories on emilszymecki/agentmachi: Security → Report a vulnerability. Please do not open a public issue for something exploitable.

Useful report:

  • the bind address the hub was started with (it decides the entry path),
  • the frames exchanged, or a script that reproduces it,
  • what identity or capability was obtained that should not have been.

This is a small project with no security team and no SLA. You will get a human answer as soon as one is available, and a fix on main if the report holds. If the finding turns out to be a documentation error rather than a code one, that counts too — this repo has a history of documentation that outlived the behaviour it described, and that is exactly the class of bug that gets people hurt.

There aren't any published security advisories