Security

The boundaries, why each exists, and exactly what relaxing one costs.

The boundaries, why each exists, and exactly what relaxing one costs.

The read/write split

Two servers, two tokens, two bind addresses.

Read server Write server
Binds your mesh address 127.0.0.1 only
Token read-token.txt write-token.txt
Tools search_notes, ask_*, code_search post_message, run_agent, agent_status, rate_answer

The write server does not prefer loopback. It reads its bind address at startup and calls SystemExit if that address is not a loopback address. A config typo cannot silently publish write tools.

A property test proves this by starting the process with a wildcard bind and a valid token and reading the exit code. The missing-token case is the negative control. Without it, a refusal for an unrelated reason would look identical to the guard working.

Why the split is not paranoia

Every argument an MCP tool receives began as model output. Model output can be steered by anything the model has read: a web page, a note, a repository someone else wrote.

A read tool talked into misbehaving returns a wrong answer. A write tool talked into misbehaving posts, runs, or deletes. Splitting the credentials means the one that travels your network cannot act.

Reachability

The dashboard is served by the read server, and a browser cannot set an Authorization header on a navigation. So it uses a session cookie:

brainlyy ui   ->  /?k=<read-token>
                 verified with compare_digest
                 HttpOnly, SameSite=Strict cookie set
                 302 to /   (token leaves the URL bar)

ui.bind controls where that is reachable:

Value Meaning
loopback (default) UI and /api/* served only to 127.0.0.1
mesh also served on your mesh address, so a phone on the same tailnet can reach it

The MCP route is unaffected by this key, and a session cookie never authenticates /mcp.

Logging in

brainlyy ui needs none of this on the machine itself, because it opens the dashboard through a one-time token URL. The passphrase login exists for the other case: opening the dashboard from a phone on the mesh, where pasting a token into a URL bar leaves it in that browser's history for good. Set one with brainlyy passphrase.

What is stored, and what is not:

  • passphrase.json holds an scrypt hash (n=2^15, r=8, p=1, a random 16-byte salt, a 32-byte key) plus those parameters, never the passphrase. scrypt is memory-hard: every guess against a stolen file costs ~32 MiB of RAM, not just CPU time.
  • A successful login sets an HttpOnly, SameSite=Strict cookie holding a signed session: HMAC-SHA256 over {iat, exp, sub} under a secret generated into session-secret.txt. Sessions last ui.session_days (default 14). Nothing is stored server-side; deleting session-secret.txt signs everyone out at once.
  • Five failures from one address lock it out for fifteen minutes, answering HTTP 429 with Retry-After, counted on a monotonic clock so sleep and clock steps cannot stretch or cancel it, and every failure and lockout is audited. The lockout refuses the correct passphrase too; the property suite proves that, because a "lockout" that keeps verifying is just a slower error message.
  • An unauthenticated / redirects to /login; /api/* still answers 401 JSON so a fetch never follows a redirect into HTML; /mcp accepts neither cookie, ever.

For a team, do not stretch this into multi-user auth. Put an OIDC-aware reverse proxy in front, such as oauth2-proxy, Cloudflare Access or Tailscale Serve, and let it own identity, MFA and revocation, which are entire products. This login exists because each of those needs an account, a third party, or a working network, and the pitch here is a tool that runs with the cable unplugged. One person, one machine, one passphrase, right-sized.

Write actions in the UI

The dashboard has three write endpoints, mounted with the rest of /api behind the same session/bearer gate:

Endpoint Body Forwarded to
POST /api/agent/run {"agent": "<name>"} (optional "prompt") run_agent
POST /api/rate {"id": "<id>", "score": <int>, "note": "<text>"} rate_answer
POST /api/post {"channel": "<name>", "text": "<text>"} post_message

None of them act. Each is proxied to the write server at 127.0.0.1:<ports.write> with the write token attached inside the proxy, so everything past that hop, the loopback bind, the token check and the remote allowlists, is enforced exactly as it would be for a local caller. The UI decides whether to forward; what a write may do is decided on the far side, where the browser's credential grants nothing.

Whether to forward is judged on the address the connection arrived on, scope["server"], the door. It is never judged on the client address, which says who is asking rather than which door they came through. A connection that arrived on 127.0.0.1 may write. Any other door needs ui.remote_writes: true.

/api/status.writes_enabled is computed by the same function that gates the endpoints, so the dashboard renders write controls only where the server would accept the write. Absent controls, not disabled ones, because a disabled button still tells you the endpoint exists.

Every answer on this path is distinguishable, on purpose:

Status Meaning
401 no session cookie or bearer; the /api gate, inherited
403 the connection did not arrive on loopback and ui.remote_writes is false; the body names that key
400 the body is not the JSON the endpoint documents
503 the write server is not running; an absent capability, not a crash, and the body says which process to start
502 the write server answered but refused the call or could not be understood
405 any method but POST; a write a GET can reach is a write a prefetcher can make
200 the write server ran the tool; the body carries its output

ui.remote_writes: true lifts the 403. Turning it on means:

  • a session cookie captured from your phone can run an agent or post a message
  • the practical value of the read/write split drops to the strength of one cookie, because the mesh-reachable surface can now act
  • the write server's own guard is still intact, since writes are proxied to it over loopback with its own token, but the thing deciding whether to proxy is now network-facing

It is off by default for those reasons. If you turn it on, prefer a tailnet with device approval, and rotate the read token if a device is lost.

Secrets

Secrets never appear in config. init generates them into separate files under ~/.brainlyy (or BRAINLYY_HOME), so a config can be committed and shared while credentials cannot. Both live outside the install, so upgrading never touches them.

Rotating a token is rm ~/.brainlyy/read-token.txt && brainlyy init, then re-register with your MCP client.

The public edge (tier 4)

If you expose the read server publicly, source-restrict the port to your assistant provider's published egress range. Reachability beats authentication: a request that cannot arrive cannot be replayed, brute-forced, or scanned.

Use HTTP-01 for ACME, never TLS-ALPN-01. TLS-ALPN-01 validates over 443, which your allowlist blocks. Renewal then fails silently, about sixty days after issuance.

Reporting a vulnerability

Open a private security advisory on the repository. Do not open a public issue.