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 frompackage.jsonat 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,
coderesponse type,authorization_codegrant, three client auth methods,S256andplainchallenge 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.
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.
| Stage | Check | Failure |
|---|---|---|
GET /oauth/authorize | client_id equals the configured id | 400 invalid_client |
response_type is code | 400 unsupported_response_type | |
redirect_uri starts with the single allowed prefix | 400 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/token | An access token is configured at all | 500 server_error |
grant_type is authorization_code | 400 unsupported_grant_type | |
client_id matches and the secret verifies — Basic header or form field, constant-time | 401 invalid_client | |
| Code exists, has not expired, and its stored client id and redirect match the request | 400 invalid_grant | |
PKCE: S256 digest or plain comparison against the stored challenge | 400 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.
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. NotSIGTERM— there is no graceful window. - Output cap, default 5 MB: checked on every
dataevent for stdout and stderr independently; exceeding it also sendsSIGKILL. - 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 yieldsexitCode: -1.formatResultreturns 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.
// 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>].
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
$ 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.
| Variable | Effect | Default |
|---|---|---|
IAN_BRAIN_TOKEN | The bearer token. Absence is fatal at boot. | required |
IAN_BRAIN_CHATGPT_ACCESS_TOKEN | A second accepted token, and the value the OAuth token endpoint issues. | unset |
IAN_BRAIN_PORT · IAN_BRAIN_HOST | Listener address. | 5050 · 127.0.0.1 |
IAN_BRAIN_PUBLIC_URL | Base URL advertised in the registry card and OAuth metadata. | a configured default |
IAN_BRAIN_OAUTH_CLIENT_ID · _SECRET | Expected client identity; an unset secret disables that check. | id set, secret unset |
IAN_BRAIN_WIKI_DIR | Root of the wiki corpus for both the read tools and the write path. | a path under $HOME |
IAN_BRAIN_QMD_URL | Referenced only in the failure message of files_search; the search itself uses the CLI. | a loopback address |
IAN_BRAIN_GRAPHIFY_PYTHON · IAN_BRAIN_GRAPH_JSON | Interpreter and graph file used by the three graph tools. | paths under $HOME |
IAN_BRAIN_PERSONAL_HINDSIGHT_URL | Memory service base URL. | a loopback address |
IAN_BRAIN_DEFAULT_MEMORY_BANK | Bank used when a call omits one. | personal |
IAN_BRAIN_DOWNSTREAMS_CONFIG | Federation config path; a missing file disables federation. | config/downstreams.json |
IAN_BRAIN_REFRESH_INTERVAL_MS | Downstream catalogue refresh period; 0 disables the timer. | 600000 |
IAN_BRAIN_CREDS_ALIASES_PATH · OP_BIN | Credential alias map and vault binary. | config/creds-aliases.json · op |
CONNECTORCTL_BIN | Device-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.