transport & trust

One boundary, and everything behind it

Authentication is a string comparison. Past it, the process can read the wiki, execute shell on remote machines and hand out live credentials. The interesting engineering is in what the server does to stay honest about that.

The HTTP surface is deliberately small: a liveness probe, a machine-readable registry card, an OAuth pair, and one authenticated POST. The MCP endpoint is stateless — a fresh server object per request, no session identifier, JSON responses enabled — so the other verbs on that path exist only to say so.

GET /health
Unauthenticated. Returns { ok, name, version, ts }, the version read from package.json at boot with a "0.0.0" fallback if that read fails.
GET /.well-known/registry-card
Unauthenticated service description: schema version, name, version, owner, endpoint, transport, and an auth block whose instructions read “Request a bearer token from the operator.”
GET /.well-known/oauth-authorization-server
Standard metadata: authorization and token endpoints, code response type, authorization_code grant, three client auth methods, S256 and plain challenge methods, one scope.
POST /mcp
The only authenticated route. Body limit 10 MB. Constructs an McpServer, registers all thirty tools, attaches a streamable HTTP transport, handles the request, and tears both down when the response closes.
GET /mcp · DELETE /mcp
405 with an explanatory body — “Stateless mode: use POST /mcp”. No SSE stream to resume, no session to delete.
The trust boundary and what sits on either side of it Unauthenticated routes — health, the registry card, OAuth metadata, authorize and token — sit outside the boundary. The authenticated POST /mcp route crosses it after a bearer check against a set of one or two configured tokens. Inside, a per-request MCP server exposes thirty tools which reach the filesystem, git, subprocess CLIs, a loopback HTTP service, remote machines over SSH and a credential vault. Every call writes a row to a SQLite audit ledger recording the tool, a truncated hash of its arguments, status and latency, but never argument values. outside the boundary GET /health { ok, name, version, ts } no auth, no side effects /.well-known/registry-card how to reach and authenticate names an introspection tool /oauth/authorize · /oauth/token issues the pre-configured access token to one known first-party client POST /mcp Authorization: Bearer <token> the only guarded route authMiddleware — one equality test no user model · no scopes · no per-tool authorisation · two possible tokens per-request McpServer new McpServer({ name, version }, { instructions }) registerAllTools(server) — all thirty, every time new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }) res.on("close") → transport.close(); mcpServer.close() nothing survives the response except audit rows and the federation registry, which is process-wide uncaught errors → 500 { error: "internal_error", message } what the boundary admits access to the wiki + arbitrary $HOME writes bash -lc on the gateway box shell on any reachable machine scp between any two machines GitHub + Workspace CLIs live secrets from the vault the mitigations are not technical: a required “reason” argument, an instruction block, and a complete ledger audit.db — better-sqlite3, WAL mode, created on boot CREATE TABLE calls (id, ts DEFAULT datetime('now'), tool, args_hash, status, latency_ms, error) indices on ts and on tool args_hash = sha256(JSON.stringify(args)).hex.slice(0, 16) — correlatable, not readable
The boundary. A single bearer token separates a directory listing from remote command execution. Nearly every handler calls logCall on both its success and its failure path — the exceptions are guard clauses that reject a call before it does any work, such as wiki_index's catch block and wiki_append's early exits, which return without writing a row.

Authentication, precisely

The middleware builds a set from two environment variables — the primary token and a second one reserved for the OAuth-issued connector token — filters out the empty ones, and tests membership. Missing configuration is a 500, not a bypass. A missing or malformed header sets WWW-Authenticate: Bearer; a wrong token sets WWW-Authenticate: Bearer error="invalid_token". Both return 401 with a distinct error_description.

Two comparison strategies in one codebase

src/oauth.ts:31-35 defines safeEquals on crypto.timingSafeEqual and uses it for the client secret and for PKCE verification. src/auth.ts:21 compares the bearer token with Set.has, which is an ordinary string comparison. The careful primitive exists in the repository; the main authentication path does not use it.

The OAuth server is a shim

It implements the authorization-code grant faithfully — and issues the same static token every time. There is no user database, no consent screen and no refresh token. Its purpose is to let a connector platform that requires an OAuth handshake complete one, and receive the pre-shared bearer token at the end of it.

The authorization-code flow as implemented — src/oauth.ts
StageCheckFailure
GET /oauth/authorizeclient_id equals the configured id400 invalid_client
response_type is code400 unsupported_response_type
redirect_uri starts with the single allowed prefix400 invalid_request
On success: 32 random bytes, base64url, stored in a process-local map with a 5-minute expiry alongside the challenge, client id, redirect and scope. 302 back with code and, if supplied, state.
POST /oauth/tokenAn access token is configured at all500 server_error
grant_type is authorization_code400 unsupported_grant_type
client_id matches and the secret verifies — Basic header or form field, constant-time401 invalid_client
Code exists, has not expired, and its stored client id and redirect match the request400 invalid_grant
PKCE: S256 digest or plain comparison against the stored challenge400 invalid_grant
On success: { access_token, token_type: "Bearer", expires_in: 31536000, scope }. The code is deleted from the map at lookup time, before any check runs, so it is single-use even on a failed exchange.

Two details are worth pulling out. If no client secret is configured, verifyClientSecret returns true — the deployment chooses whether that check exists. And if no challenge was stored, verifyPkce also returns true, so PKCE is enforced only for clients that opted into it. Both are the permissive branch of an optional feature, and both are one line.

The audit ledger

One table, two indices, WAL mode, opened once at boot before the token check. The logCall helper is a no-op if the database was never initialised, so a failure to open it degrades logging rather than the server.

src/audit.ts:32-44logCall()
export function logCall(
  tool: string,
  args: unknown,
  status: 'success' | 'error',
  latencyMs: number,
  error?: string,
): void {
  if (!db) return;
  const argsHash = createHash('sha256').update(JSON.stringify(args ?? {})).digest('hex').slice(0, 16);
  db.prepare(
    'INSERT INTO calls (tool, args_hash, status, latency_ms, error) VALUES (?, ?, ?, ?, ?)',
  ).run(tool, argsHash, status, latencyMs, error ?? null);
}

Callers choose what to hash, and they choose conservatively: files_search passes the query truncated to 100 characters, creds_token_get passes the alias and the reason but never the value, machines_command_exec passes the host, the first 200 characters of the command and the route it took. The hash makes repeated identical calls correlatable without making any of them readable. The data/ directory it lives in is excluded by .gitignore, with a comment noting it may contain captured tool arguments.

Everything eventually becomes a subprocess

Search, graph traversal, credential reads, remote execution, file copies and both CLIs run through the same eighty-line wrapper. It uses spawn with an argument array — never a shell string — copies the environment with overrides applied on top, and enforces two independent limits.

Limits

  • Timeout, default 60 s: sets a flag and sends SIGKILL. Not SIGTERM — there is no graceful window.
  • Output cap, default 5 MB: checked on every data event for stdout and stderr independently; exceeding it also sends SIGKILL.
  • Both buffers are sliced to the cap before being returned, so a burst that arrives in one chunk cannot exceed it either.

Result shape

  • { stdout, stderr, exitCode, timedOut }; a process killed before reporting yields exitCode: -1.
  • formatResult returns only stdout on a clean exit — no framing, no noise.
  • On any failure it returns Exit <code>, a (TIMEOUT) marker where relevant, then stderr, then stdout.

That asymmetry matters for tool output. On success the model sees exactly what the command printed and nothing else; on failure it sees a small structured report it can reason about.

A fixed injection, documented in place

The longest comment in the repository sits above files_read and explains a shell-injection bug and its fix. It is reproduced here in full because it is a better argument for argument arrays than any abstract one.

src/tools/files.ts:365-376why files_read no longer touches a shell
// Security fix 2026-05-24: pass path as separate argv to spawn (no shell parsing).
// Previous version used `bash -lc head -c N "<path>"` with JSON.stringify(path).
// JSON.stringify does NOT escape `$` or backticks — so a malicious path like
// `"$(rm -rf ~)"` would execute via bash double-quote command substitution.
// Now: head executes directly via execvp; path is a literal argv string, no shell sees it.
if (host === 'local') {
  r = await runShell('head', ['-c', String(max), input.path], { timeoutMs: 30_000, maxBytes: max + 1024 });
} else {
  r = await runShell('ssh', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new',
    host, 'head', '-c', String(max), input.path], { timeoutMs: 30_000, maxBytes: max + 1024 });
}

The remote branch is subtler than the comment suggests: ssh concatenates its trailing arguments and the login shell on the far side parses the result, so the local spawn argument array does not by itself neutralise remote expansion. The pattern is applied consistently elsewhere in the module — machines_log_tail is the one place that still builds a command string, and it wraps the path in JSON.stringify before passing it to bash -lc (src/tools/machines.ts:543).

Routing a command to a machine

machines_command_exec takes a host that may be a device id, an alias, a hostname, an IP or user@host, and resolves it against two inventories fetched in parallel. The routing ladder has five rungs and reports which one it used in the response — every result is prefixed [via <route>].

The five-step command routing ladder A host string is split into an optional user and a bare host. The literal values local or self run immediately in a local shell. Otherwise two device inventories are listed in parallel. A device-manager match runs locally if the device is this gateway, otherwise through the device manager's remote shell; if that reports that remote shell automation is unavailable and the same machine appears in the mesh inventory, it falls back to mesh SSH. A mesh match runs locally when the peer is this host, reports offline when it is not reachable, and otherwise resolves a default SSH user and connects by IP. If nothing matches, a plain SSH attempt is made with the supplied host. machines_command_exec({ host, command }) splitUserHost — everything before “@” is the user host is “local” or “self” ? bash -lc, via “local” Promise.all — list device-manager devices and mesh peers both via a CLI with a 5 s timeout; either may return an empty list 1 · matches a managed device identity compared with all punctuation stripped is this gateway ⇒ run locally instead of SSH to self else ⇒ device manager’s remote shell, bash -lc JSON exit_code / stdout / stderr envelope unwrapped 2 · that device has no remote shell four known error strings are matched, case-insensitively the same machine is looked up in the mesh inventory found ⇒ retry over mesh SSH route reported as “device(id)->mesh-ssh(target)” 3 · matches a mesh peer offline ⇒ exit 255 with an explanatory stderr, no attempt is this host ⇒ run locally else ⇒ ssh to the peer’s mesh IP SSH user from the request, else a per-host default table 4–5 · no match anywhere plain ssh with the host exactly as supplied every SSH attempt, same options BatchMode=yes · NumberOfPasswordPrompts=0 KbdInteractiveAuthentication=no · PasswordAuthentication=no PreferredAuthentications=publickey · ConnectTimeout=15
Five rungs, one report. Public-key authentication is mandatory on every SSH path — password and keyboard-interactive prompts are disabled outright, so a host that is not already trusted fails fast instead of hanging on a prompt no agent can answer.

A companion tool, machines_access_guide, exists purely to answer a question the model would otherwise get wrong: which access route fits a task. It returns no access at all — only a resolved view of the target and a set of recommendations, including the literal command a human should paste into their own terminal for an interactive session. Its caveats section is the honest part: “[Mesh] online, SSH ready, and remote desktop ready are separate states.”

Connecting to it

probing the endpoint
$ curl -s "$GATEWAY/health"
{"ok":true,"name":"ian-brain","version":"0.3.0","ts":"2026-07-28T00:00:00.000Z"}

$ curl -s -i -X POST "$GATEWAY/mcp" -d '{}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
{"error":"invalid_token","error_description":"Missing Authorization header"}

$ curl -s -i -X POST "$GATEWAY/mcp" -H "Authorization: Bearer wrong" -d '{}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
{"error":"invalid_token","error_description":"Token mismatch"}

$ curl -s "$GATEWAY/mcp"
{"error":"method_not_allowed","message":"Stateless mode: use POST /mcp"}

# register it with a client — from the project README
$ claude mcp add --transport http ian-brain "$GATEWAY/mcp" \
    --header "Authorization: Bearer $TOKEN"

Status codes, headers and JSON bodies are exactly those constructed in src/auth.ts:13-25, src/server.ts:44-46 and :135-140. The gateway address and token are shell variables here; the real values do not appear on this site.

Configuration surface

Every operational decision is an environment variable with a default, which is what makes the repository readable without access to the machine it runs on.

Environment variables read at runtime
VariableEffectDefault
IAN_BRAIN_TOKENThe bearer token. Absence is fatal at boot.required
IAN_BRAIN_CHATGPT_ACCESS_TOKENA second accepted token, and the value the OAuth token endpoint issues.unset
IAN_BRAIN_PORT · IAN_BRAIN_HOSTListener address.5050 · 127.0.0.1
IAN_BRAIN_PUBLIC_URLBase URL advertised in the registry card and OAuth metadata.a configured default
IAN_BRAIN_OAUTH_CLIENT_ID · _SECRETExpected client identity; an unset secret disables that check.id set, secret unset
IAN_BRAIN_WIKI_DIRRoot of the wiki corpus for both the read tools and the write path.a path under $HOME
IAN_BRAIN_QMD_URLReferenced only in the failure message of files_search; the search itself uses the CLI.a loopback address
IAN_BRAIN_GRAPHIFY_PYTHON · IAN_BRAIN_GRAPH_JSONInterpreter and graph file used by the three graph tools.paths under $HOME
IAN_BRAIN_PERSONAL_HINDSIGHT_URLMemory service base URL.a loopback address
IAN_BRAIN_DEFAULT_MEMORY_BANKBank used when a call omits one.personal
IAN_BRAIN_DOWNSTREAMS_CONFIGFederation config path; a missing file disables federation.config/downstreams.json
IAN_BRAIN_REFRESH_INTERVAL_MSDownstream catalogue refresh period; 0 disables the timer.600000
IAN_BRAIN_CREDS_ALIASES_PATH · OP_BINCredential alias map and vault binary.config/creds-aliases.json · op
CONNECTORCTL_BINDevice-manager CLI used for the machine inventory and remote shell.a binary name on PATH

Secrets are referenced, never stored

The credential module holds a map of friendly aliases to vault references and shells out to the vault CLI to resolve them. creds_token_list returns the alias names and their references but never a value. .gitignore is written defensively around the same principle: patterns for *token*, *secret* and *credential* sit under a header reading “NEVER COMMIT”, and the audit database directory is excluded with a note that it may contain captured tool arguments.

Shutting down

SIGTERM and SIGINT both run the same handler: close every federated client, stop accepting connections, exit zero when the server closes. A five-second timer forces exit if that never happens — and it is unref'd, so a clean shutdown is not delayed by the timer it set to guard itself.

Treat it as the authorized access layer for machines, project systems, credentials, memory, wiki, and workspace tools.

src/server.ts:79 — the first line every client reads

Hostnames, domains, ports beyond documented defaults, credential aliases, vault names, account identities and token values are omitted throughout. $GATEWAY and $TOKEN are placeholders for values held by the operator.