Terminal through a host service with a Noise end-to-end stream and invariant I6¶
Context and Problem Statement¶
The owner requires a terminal as a mandatory component (follow-up of 2026-10-10): shell and sessions of a coding agent on connected hosts, in desktop, phone and web console, with a separate permission per host, renewed passkey confirmation when opening, access only through a private network or an end-to-end tunnel, audit, idle timeout and a separate user without administrator rights on the host. The product must not require Tailscale (requirement 21), the server is the authorization point (B7), agent content and credentials stay out of the server (B10, I5). A terminal allows any use of the host's LLM login; Anthropic allows subscription logins only for one's own use (https://code.claude.com/docs/en/legal-and-compliance, retrieved 2026-10-10 [verified]). How does a terminal get securely from the host to the device, and who may open it?
Decision Drivers¶
- No inbound port on the host; works behind NAT, in the private network and in the cloud.
- The server decides every opening but reads no content.
- A reviewed, small standard with public test vectors instead of own cryptography.
- No back door around the permission model; agents and apps never open a terminal.
- Personal subscriptions only for their holder, also on hosts with several owners.
- An honest protection goal: what the encryption achieves and what it does not.
Considered Options¶
- Host service with an outbound WebSocket connection, server as relay, Noise
NKend-to-end - SSH to the host over the private network (Tailscale or VPN), server only issues credentials
- Host service with a TLS stream that the server terminates and forwards (not end-to-end)
- WebRTC data channel between device and host with signaling through the server
Decision Outcome¶
Chosen: "Host service with an outbound WebSocket connection, server as relay, Noise NK", because
only this option works without an inbound port and without requiring Tailscale, makes the server
the single decision point and still protects the content from relay, logs and database.
Normative: 24-terminal.md.
- Host service
bothy-host(separate Go binary,services/host) runs as a user without administrator rights, checks this itself (doctor) and talks to the server only outbound. The sessions run as the session user of their person (item 7), who cannot read the keys of the host and cannot replace its PTY helper (system service, HD10); a second live control connection with the same keys disables the host. Withpersonalthe host service does not touch any subscription login (D-W-17, D-W-18). - Noise
Noise_NK_25519_ChaChaPoly_SHA256(revision 34, https://noiseprotocol.org/noise.html, retrieved 2026-10-10 [verified]). The device knows the static key of the host from the pairing. Prologue with protocol version, session ID and host ID. - Terminal session token in the first handshake packet: EdDSA JWS of the server, valid exactly
once, bound to the ephemeral key of the device (
cek). The PTY starts only after the first valid transport message. Reason: according to the specification the first message ofNKcan be replayed (section 7.7 [verified]). - Permissions: a separate target
hostwith only the capabilityterminal, only for people and groups; owners implicitly. Guard rule SR10: non-owners only on their own host, the server host only for owners. - Invariant I6: with
llm_credentials = personal, only the holder of the login opens a terminal, not even another owner; only an individual grant to that holder. Checked transactionally and on every opening. - Step-up fresh and consumed per opening.
- One session user per person (ruling T12, see below): every person has their own session user on every host, never shared; without an assignment no terminal (SR12).
Distinction from ADR 0024 and
ruling L22: I1 has no owner exception because owners inherit trigger on every target and an agent
is reachable through many targets. A terminal is access to exactly one host, not an inherited
level. Without the owner exception, an instance with two owners could not have a personal host.
The exception restricts owners and extends nothing; L22 stays unchanged for I1.
Addendum 2026-10-11: one session user per person and host (ruling T12)¶
Until this addendum, all sessions of a host ran under one shared
session user. "No subscription login of another person" then depended on HD6, a check in the
account of exactly this user. Three critique rounds found the same error class: the result could be
faked from the account (program in the PATH, K2-19), was switched off under macOS without
host_service.claude_bin (K3-01) or could be faked again through an interpreter (K3-06). A login
that a person created with /login was then used by every later session of other people.
Decision: the error class is fixed in one place, not per finding.
- The server holds, per host, the assignment of person to session user (
host_session_users); the owner creates it at the confirmation or withhost.manage. Without a valid assignment a person gets no terminal, not even an owner (SR12,terminal_deniedwithreason: no_session_user). - A session user belongs to at most one person, also after the assignment ends; the name
stays taken for the host (
409 conflictwithreason: session_user_taken). - The terminal session token names the session user (
osu); the host service starts the PTY only in the helper of this user and binds every session user to the first person who received it. - HD6 remains as an additional check and notice (ruling T2 still applies: a contradiction to the
declaration prevents the start). Its result is no longer the boundary; an empty
host_service.claude_bindoes not weaken enforcement. - I6, SR10 (ruling T1) and the owner exception (ruling T2) stay unchanged and apply in addition.
- The host service reports the names of its session users (
hosts.reported_session_users); the owner assigns only reported names (otherwise400 validation_failed), and an assignment takes effect only as long as the host reports its name (D-W-63). This way a typo in the assignment shows up there and not as a tampering warning4104when opening.
Rejected: treating HD6 with an empty host_service.claude_bin under macOS as an error (proposal
from K3-01). Reason: that closes only this one instance; every further gap in the oracle
(interpreter, modules, data of the same user ID) would remain a gap in the boundary. Separate
accounts make the rule true independently of any check. Separation by user IDs is not a
complete boundary between people (ADR 0046); that is why SR10 still applies.
Addendum 2026-10-11: no shared locations (ruling T13)¶
Separate session users separate people only if no location connects their accounts again. Critique
round 4 found two such locations: working directories of the profile claude as one list for all
session users (project settings with hooks and apiKeyHelper, CLAUDE.md in the
folder chain then run in the account of another person) and a CLAUDE_CONFIG_DIR without a fixed
owner (a /login in it reached everyone). This is the same error class as before T12.
Decision: one rule and one check instead of a patch per location.
- On a host there is no location that one session user can write and another can read or execute, if it carries code, settings or logins.
- Working directories are listed per session user in
host_service.claude_workdirsand belong to that user; a folder under two session users drops out of both lists (HD7); the index of the working directory counts only in its reported list (claimwdi); theCLAUDE_CONFIG_DIRlies, withorganization, at a fixed place in the session user's home and, withpersonal, only within the home; programs, including their folder chain, belong to a trusted owner (root, under WindowsSYSTEM,AdministratorsorNT SERVICE\TrustedInstaller); withorganizationthe profileclaudestarts exactlyhost_service.claude_bin. - The doctor check HD11 checks this with one rule for every configured path and the home,
including the folder chain and the chain files in it (
CLAUDE.md,CLAUDE.local.md,AGENTS.md,.mcp.json,.claude,.git: what the coding agent and git load from the folders above the working directory), before every PTY and periodically; a violation blocks only the affected session user, and at a location for everyone, the whole host service. Every path is fully resolved first, the locations of a session user by its PTY helper; a path that cannot be resolved counts as not present. "Writable" includes creating entries for folders and appending for files. Under Windows,bothy-host install, after confirmation, removes the right of all authenticated users to create folders in the root of a drive, without changing the permissions of the folders below (24 section 2.2 item 6, D-W-80, D-W-83, D-W-84, D-W-86, D-W-87). - Shared code is exchanged through git, never through a shared folder.
Rejected: a separate check per location (HD7 for the ownership of the working directories, HD9 for working directories of others, as the critique proposed). Reason: every new location would again need its own check and would again be found individually. The limit, stated honestly: HD11 does not see folders outside the configuration, for example a shared folder in which a person starts the coding agent themselves in their shell; per 16 section 4.4 the instance operator does not set up such a folder. A file that a later version of the coding agent additionally loads from the folder chain is checked by HD11 only once the list of chain files has been extended (D-W-62, D-W-68, threat model T-TM-13).
Consequences¶
- Good, because the host opens no port and the product works without a private network.
- Good, because relay, proxy, logs, database dump and an eavesdropping api process see no content.
- Good, because every opening goes through
authorizeand is recorded in the audit. - Bad, because a malicious instance operator of the server can distribute their own key and allow
sessions. Mitigation: key pinning per device with a hard warning, audit of every
re-pairing, option
host_service.require_local_confirm; listed in the threat model as a residual risk (ruling T3). - Bad, because several server nodes need hash routing in the proxy so that both ends of a session land on the same node (ruling T6).
- Bad, because the product cannot check the content of an LLM login; I6 depends on the owner's declaration. The host service checks whether a subscription login is present and refuses the start on a contradiction; but what carries the rule is the separate session user per person (ruling T12).
- Bad, because the instance operator sets up an operating system user and a PTY helper per person and the owner must assign every person before that person gets a terminal.
- Bad, because people cannot use a shared working directory on a host; each person works in their own clone and exchanges changes through git (ruling T13).
- Bad, because a single person with a subscription login in their account can bring a host with
noneororganizationto a standstill for everyone (ruling T2); the message names every affected session user for this, also on a hit at start (D-W-91), and the instance operator ends the standstill by removing the login or taking the session user out withbothy-host session-user remove(D-W-64, D-W-67, threat model T-TM-14). The same applies to a chain file of a person in a shared folder on the path to the locations of others; such files exist only from a time when this folder was writable, and the instance operator removes them (D-W-80). A home that a person with terminal access opens for others, by contrast, blocks only that person's own session user (HD11, D-W-88). If a long-running process of the person reopens the home of their former session user afterbothy-host session-user remove, HD9 refuses the start; the message names this user, and the instance operator deletes it together with its long-running processes (D-W-109). - Bad, because every restart of the host service ends all sessions of the host, including
bothy-host session-user addandremove, which restart it so that the changed list of session users applies (D-W-90); the instance operator chooses a quiet time for this.
Confirmation¶
- Conformance with public Noise test vectors (cacophony, noise-c; 24 section 5.2) in Go
and Dart (TM-T01) and a cross test of Go against Dart (TM-T02). Since M2-38: TM-T01 is held by
TestVectorsTMT01inservices/host/internal/noiseandtest/vectors_test.dartinpackages/bothy_noise; TM-T02 is held byTestRecordingsTMT02DartandTestRecordingsTMT02Negativein Go andtest/recordings_test.dartin Dart, over the recordings of both languages in both roles. - Separation of users and disabling on a second control connection (TM-T22, TM-T23). Since M2-43
TM-T23 holds
TestDuplicate_TMT23inservices/server/internal/terminal/adapters/wsrelay. - Since M2-42 the host side of TM-T13, TM-T22, TM-T24, TM-T26, TM-T27 and the TM-T29 parts (a),
(b), (c), (d), (e), (f), (h), (i), (j) holds the platform suite of
services/host/test/platform(TestPlatform_TMT13,TestPlatform_TMT22,TestPlatform_TMT24,TestPlatform_TMT26,TestPlatform_TMT27,TestPlatform_TMT29_Parts,TestPlatform_TMT21_SessionUserAddRemove) on disposable GitHub-hosted runners; the unit testsTestHD1toTestHD11,TestEntryScanSemantics,TestReportNamesRule, the peer checks ofinternal/localchanandTestHostPairAgainstServerinservices/server/test/terminalhold the rules on every build host. Until a recorded platform run proves a platform, HD10 fails there withpeer_check_unavailableand the host gives no terminal (docs/DECISIONS.md D-M2-42-19). - Replay and token tests in the host service (TM-T03 to TM-T05). Since M2-45 they hold in
services/host/internal/session:TestTMT03Replay,TestTMT04TokenFailures,TestTMT05NoStart,TestVerifyOrder(the check order of 24 5.3 with one row per step) and the fuzz targetFuzzVerifySessionToken. The host module verifies session tokens with its own smaller implementation of the ADR 0067 profile, tested with the Ed25519 example of RFC 8037 A.4 (TestVerifyRFC8037Vector) and the negative cases ofTestVerifyOrder(alg,typ,crit,jwk, extra dots,nulland missing claims) (docs/decisions.d/M2-45.md D-M2-45-5). - Since M2-45 the host side of TM-T21, TM-T25, TM-T28 (c) and (d) and the TM-T29 parts with a
session holds the platform suite of
services/host/test/platform(TestPlatform_TMT21_Sessions,TestPlatform_TMT25,TestPlatform_TMT28c,TestPlatform_TMT28d,TestPlatform_TMT29a_Session,TestPlatform_TMT29c_Open,TestPlatform_TMT29f_Operation,TestPlatform_TMT29g_Token,TestPlatform_TMT29k,TestPlatform_TMT29l) on disposable GitHub-hosted runners (docs/decisions.d/M2-45.md D-M2-45-18). - Canary test over relay, logs, database and telemetry (TM-T06).
- Permission test matrix AZ-M54 to AZ-M61 and I6 tests (TM-T09) in
make test. - One session user per person: TM-T28 (without assignment, taken name, wrong
osu, subscription in the account of another person, unreported name) and home folders of others (TM-T24: HD9 against users who are not session users; an open home of a session user blocks, per HD11, only that user; a long-running process reopens the home afterremove, and HD9 names the former session user). - No shared locations: TM-T29 (working directory writable by others with a hook and
apiKeyHelper, folder chain, duplicate working directory, readableCLAUDE_CONFIG_DIR, entry of another user, writable program or program of another user, index of another user, chain file of another user). - Standstill through HD6 and its end, also with a hit at start: TM-T21.
- Two api nodes with and without hash routing (TM-T15). Since M2-47 TM-T15 holds
TestTMT15_TwoNodesWithHashRouting,TestTMT15_WithoutHashRoutingandTestTMT15_WithoutHashRouting_Attachinservices/server/test/terminal/multinode, at the server level with the fake host of M2-44; M2-48 runs it again end to end with the real host service. - Since M2-48 the tests of 24 section 14 run as one data-driven conformance suite in
services/server/test/terminal: scenario files run end to end in everymake checkagainst the server image built from the worktree, real host servicesbothy-hostin Linux containers and a Go test device that speaks Noise throughinternal/noiseandinternal/frameof the host service. The platform suiteservices/host/test/platformruns in a Linux container in everymake checkand, as a bundle ofmake -C services/host platform-bundle, on disposable macOS, Windows 10 and Windows 11 test machines. TM-T16 runs inmake checkand again under load inmake perf(TestChaosTMT16inservices/server/test/chaos). The catalogueservices/server/test/terminal/catalogue.yamlnames every run, andmake docsholds it equal to 24 section 14 and threat model section 6.7. - Red team terminal at the end of M2, M4, M5, M6 (threat model, section 6.7).
Pros and Cons of the Options¶
Host service, relay, Noise NK¶
- Good, because it is outbound, without network prerequisites and end-to-end encrypted.
- Good, because
github.com/flynn/noise(BSD-3-Clause, https://pkg.go.dev/github.com/flynn/noise, retrieved 2026-10-10 [verified]) bringsHandshakeNKandPrologue. - Bad, because a separate package is needed for the Dart handshake.
SSH over the private network¶
- Good, because it is proven and usable with existing tools.
- Bad, because the host opens a port and the product requires a private network (requirement 21).
- Bad, because the server no longer decides the opening itself but only issues keys; revocation takes effect only when the key expires.
TLS to the server, server forwards¶
- Good, because it is simple.
- Bad, because the server sees every keystroke and every output in plain text; a dump of an api process or a logging error exposes shell content.
WebRTC data channel¶
- Good, because direct connections relieve the server.
- Bad, because STUN and TURN are needed as additional services and the relay is needed anyway.
- Bad, because the browser stack and the libraries of the platforms have a large attack surface.
More Information¶
- Related: ADR 0020 (step-up), ADR 0024 (I1), ADR 0046 (dev isolation), ADR 0067 (JOSE profile), ADR 0070 (terminal in the web console).
- Rulings T1 to T6, T9, T10, T12 and T13 of the lead (DECISIONS, D-W-57, D-W-62).
- Review: before publication (external test P2) and when changing the Noise library.