docs GitHub

Architecture

ADR 0004: Presence: proving a person is there before a human-only action

Status: accepted, 26 Sep 2026 · Workstream: security · Spec: principles 7, sections 5, 7.5, 7.7, 7.11, 8 and 11 (floor rules 1, 2 and 8)

The problem

Some actions are the user's alone: approving, revising or rejecting what the Gate holds (gate.approve, gate.revise, gate.reject), answering a permission question (threads.answer), putting, approving or unlocking vault items (vault.put, vault.approve, vault.unlock), offboarding (vault.offboard), and accepting or retiring a lesson (learn.accept, learn.retire).

Until now vyred told people from models by the caller, and the caller is only a claim. A client names itself in the x-vyre-caller header on a unix socket the user owns, and Claude Code runs as that same user. A Bash command Claude runs can therefore:

  • curl --unix-socket ~/.vyre/vyred.sock -H 'x-vyre-caller: cli' .../v1/tools/gate.approve;
  • run vyre call gate.approve '{"id":"..."}', which calls as cli;
  • claim capsule, deck or local, which the Gate also trusts.

Any of these lets a model approve its own held email. That breaks floor rules 1 and 2: nothing goes out as the user until the user has seen the final words, and the user always sees where it is going.

The defences so far were thin. vyred treats module:* and hook claims as local. Learning asks before commands that name vyre.db or the socket. The floor denies reading the vault folder. None of this stops the first two routes above.

What Claude Code gives a Bash command

Checked on this machine (Claude Code in Terminal, 26 Sep 2026). The claude process has a controlling terminal (ttys012). The Bash tool's shell is started in a new session with none: tty says "not a tty", stdin and stdout are pipes, ps shows the shell's TTY as ??, and opening /dev/tty fails with ENXIO ("device not configured").

So "is stdin a terminal" already fails for the model's commands. It is still not a proof:

  • script -q /dev/null vyre ... (or Python's pty, or expect) gives the child a fresh pseudo-terminal, and whoever holds its master can read the prompt and type the answer.
  • A terminal check that the CLI runs on itself is a claim too. The model can write its own client that skips the check.

A proof has to be checked by vyred, and it has to need something the model cannot do.

Decision

Two independent layers. Either one on its own stops the routes above.

Layer 1 · Presence proof, checked by vyred

A tool that only a person may run declares it:

ctx.tool("gate.approve", { presence: true, ... })
// or, with the words the person must see before proving it:
ctx.tool("gate.approve", { presence: { summary: async input => "Send email to [email protected]: \"Hi Bob…\"" }, ... })

The floor also keeps its own list, in core/presence/index.js, so these tools need presence even before their owners declare it, and a module cannot declare its way out: gate.approve, gate.revise, gate.reject, threads.answer, vault.put, vault.approve, vault.unlock, vault.offboard, learn.accept, learn.retire, and presence's own presence.enroll, presence.remove and presence.code. A module's declaration can only add to this list (principle 7).

Registry.call(tool, input, caller, { proof }) refuses a presence tool with { error: { code: "presence_required", methods } } (HTTP 403) unless the proof checks out, whatever the caller claims. Two things are exempt:

  • Module callers (module:<name>). Only the loader can make these, and modules are code the user installed. A module that turns an outside event into a presence call (the chat module turning a Mattermost button into gate.approve) must prove the person itself, for example by the Mattermost user id, and the Gate's approvers list still decides which modules may approve.
  • A registry built without a presence verifier. Only unit tests build one. vyred always builds one, and a test checks that it does.

A proof is bound to one tool and one input: the SHA-256 of the canonical JSON of the input (keys sorted, no spaces). It is used once and expires after 2 minutes. A proof for "approve item A" cannot approve item B.

The proof travels in one header, x-vyre-presence: <method> k=v k=v .... Binary values are base64url, and ids and codes are plain tokens in the same alphabet.

MethodWhereProofWhat the model cannot do
touchidthe Macvyred itself shows the macOS authentication dialog (Touch ID, Watch or password), with the summary as its reasonpress the sensor or type into the system dialog, which takes no synthetic keystrokes
ttyany terminal, the box over SSHvyred writes a code and the summary straight to a login terminal, and the person types the code backread what is written to a terminal it does not hold the master of; a script pty is not a login terminal
capsulethe Capsulean ES256 (P-256) signature over the call by a per-install key the native Capsule keeps in the Mac's Secure Enclave, made only after a click and a live Touch ID each time (biometryCurrentSet). vyred checks it only against the key enrolled for that id. Since 28 Sep (ADR 0040); an Ed25519 key from before is refused and must be re-enrolledsign with a key that never leaves the Secure Enclave without the person's finger on the sensor
passkeythe Deck, on the tailnet or locallya WebAuthn assertion with user verification, over a challenge from vyredmake a platform passkey assertion; that needs the device's biometric
codeenrolling a passkeya one-time code from presence.code, which itself needs presence(only accepted by presence.enroll)

Challenges. POST /v1/presence/challenge { tool, input, method, ... } starts a proof that needs one:

  • tty, with tty: "/dev/ttys003". vyred checks that the path is a terminal device owned by this user, and that who lists it as a login session. Terminal.app, iTerm, sshd and getty sessions are listed; script, expect, Python pty and tmux panes are not. vyred then writes, straight to that device: the summary and a 6-character code. It returns { challenge }. The client asks the person for the code and retries the call with x-vyre-presence: tty id=<challenge> code=<code>. After three wrong codes the challenge is gone.
  • passkey. vyred returns { challenge, webauthn: { challenge, rpId, allowCredentials, userVerification: "required" } }. The Deck calls navigator.credentials.get and retries the call with x-vyre-presence: passkey id=<challenge> cred=<id> ad=<authenticatorData> cd=<clientDataJSON> sig=<signature>. vyred checks the type (webauthn.get), the challenge, the rpId hash, the UP and UV flags, and the signature (ES256, EdDSA or RS256) against the key enrolled for that credential.

touchid and capsule need no challenge:

  • touchid. The call carries x-vyre-presence: touchid, and vyred runs its authentication helper (core/presence/touchid/, built from Swift with swiftc on first use, and hash-checked before each run). vyred shows one dialog at a time. After a cancel it waits 30 seconds before it shows another, so a model cannot wear the user down with repeated dialogs.
  • capsule. The call carries x-vyre-presence: capsule key=<id> ts=<ms> nonce=<n> sig=<s>. The signed message is vyre-presence-v1\n<tool>\n<input hash>\n<ts>\n<nonce>. The timestamp must be within 60 seconds and the nonce unused.

Enrollment. Capsule keys and passkeys are rows in the presence_keys table in vyre.db, public keys only. presence.enroll and presence.remove need presence themselves. The first key on a Mac is enrolled with Touch ID. On the box it is enrolled with a tty proof over SSH, or with a presence.code minted at such a terminal and typed into the Deck.

What the person sees. Every method shows the tool's summary before the person proves anything: in the dialog's reason, on the terminal, in the Capsule and in the Deck. A tool with no summary function is shown as its name and a short form of its input. That is legal but poor, and owners should supply a summary. For the Gate, the summary is the destination and the start of the final content (floor rules 1 and 2).

Layer 2 · The floor closes the model's routes

The PreToolUse floor (core/harness/rules.js) also runs in-process when vyred is down (see harness/hooks/hook.js). It denies the model's ways around layer 1 before they run:

  • Human-only vyre commands. vyre call <presence tool>, vyre gate approve|revise|reject, vyre threads answer, vyre vault put|approve|unlock|offboard, vyre learn accept|retire and vyre presence ..., however the command is reached: a path to bin/vyre, node .../bin/vyre, npx vyre, env, sh -c, bash -c, eval, xargs, quotes split inside words, backslashes, or $'...'. A vyre command whose words come from variables, command substitution or globs is asked about, not allowed.
  • Raw socket clients. curl --unix-socket, nc -U, socat UNIX-*, Python AF_UNIX and Node socketPath, aimed at vyred.sock, the /tmp/vyre-<uid> folder or anything in VYRE_HOME, are denied. So is any command that writes an x-vyre-caller or x-vyre-presence header. Unix socket access whose target cannot be read from the command is asked about.
  • Vyre's internals. Reading, listing, copying or writing vyre.db, the socket, config.json, keys, logs and the vault under VYRE_HOME is denied, by Read, Write, Edit, Grep, Glob and Bash, including through $HOME, ${HOME}, ~ and globs that could match .vyre. VYRE_HOME's watchers/ and modules/ stay open, since the write-a-watcher skill works there. Writing into modules/ is asked about, because a module runs inside vyred.

Layer 2 is a filter over shell text, and shell text can always be obfuscated further than a filter can read. That is why layer 1 exists. Layer 2 turns an easy bypass into a visible, refused attempt that Learning records.

Agent identity (the switchboard's work)

A different question: which agent is calling. The Switchboard gives each agent's child process VYRE_AGENT_KEY, and vyred believes an *agent:<name> caller only with the key of a live thread of that agent (threads.vouch). It stops one agent posing as another, or as the assistant. It does not prove a person is there, and presence does not name agents. The two checks are independent, and a presence tool refuses an unproven agent whichever name it claims.

Tailnet identity is not presence

The box identifies a Deck request by tailscale whois of its source address (ADR 0002). That proves which device and login sent it, not that a person is there. Claude Code on the user's Mac is on the same tailnet as the same login, and it can reach the box's listener with curl. So tailnet:<login> gets no exemption, and the Deck proves presence with a passkey like everything else.

What this does not defend

Code running as the user can rewrite Vyre itself: edit core/presence, restart vyred, swap the helper binary or write a row into vyre.db. Layer 2 denies and asks about the direct forms of this, and the helper is hash-checked, but no user-space program can fully defend itself from its own user. The line we hold: a model that uses Vyre's interfaces, including the socket, the CLI and the MCP server, cannot complete a human-only action without a person proving presence after seeing the summary.

Two residual risks in the terminal method, both of which still need a person:

  • A model can start vyre with its input and output pointed at the user's own login terminal (the one running claude). vyred then writes the code and summary to that screen, and the model's client waits for keystrokes. It completes only if the person types the code shown next to the summary.
  • On a Mac, a model can open a new Terminal window with osascript. If the user has also given that terminal Automation or Accessibility access, the model may be able to read the window back. On a Mac with Touch ID, vyre uses touchid first for this reason.

Consequences

  • Owners of human-only tools add presence: true or a presence.summary, and their tests pass a proof or call through a registry without a verifier.
  • The CLI (core/cli/presence.js) handles presence_required itself: touchid on a Mac that has it, else tty. For every method it first needs a controlling terminal, so a process with none (the Bash tool) cannot even make a Touch ID dialog appear. vyre call and every command that reaches a presence tool go through it.
  • The Capsule enrolls a key at first run (with Touch ID) and signs presence calls. The Deck enrolls a passkey and sends assertions.
  • GET /v1/tools marks presence tools with presence: true, so a surface can prompt before it calls.
  • New events: presence.proved and presence.refused, holding the tool, method and caller, and never a code or key.

Addendum, 26 Sep 2026: after review with the other workstreams

The floor's list grew. It now includes the vault's value-out tools (vault.inject, vault.totp, vault.backup, vault.restore, vault.delete, vault.device.code, vault.device.unlock, vault.unlock-passphrase, vault.reveal, vault.copy, vault.resolve, vault.render, vault.session.open, vault.export, vault.kit), learn.relax, learn.skill-install, computers.takeover, computers.giveback and link.pair.approve.

Tools with a pending flow for Claude stay off the list, because their approval step is on it: vault.grant and vault.pass.create wait for vault.approve. vault.import stays off too. An owner who wants presence on any of these declares it. The list is the minimum the floor needs, and a declaration only ever adds to it.

What a tool sees. run(input, { caller, thread, agent, peer, presence }), where presence = { method, keyId }. keyId is the enrolled Capsule key or passkey, or null. The proof itself never reaches a tool.

The terminal method on the box. The box never takes a terminal code (role: "box"), for two reasons:

  • The Mac that Claude runs on usually holds the user's SSH keys, and ssh -t box gives a model a login terminal whose client end it holds.
  • In the box's container, vyre reaches vyred through docker compose exec, whose terminal no who lists. Nothing the wrapper could pass in would be more trustworthy than what Claude inside the container could pass.

The first passkey comes from onboarding instead. onboard.finish mints a presence.code as a module caller and hands the person a Deck link carrying it. Pairing and every later approval on the box use a passkey. Claude's sessions share the box's socket and can ask onboarding for a fresh code, so on the box a code enrolls a passkey only from tailnet:<network.owner>, which is reachable only over WireGuard from another of the owner's devices. The passkey must also be for the box's own address (the host of network.address), whatever the request names. Onboarding hands the code only to the box's own socket and its loopback page, never to a tailnet caller. So Claude on the box can hold a code but cannot be the owner on the tailnet, and Claude on the Mac is the owner on the tailnet but never sees a code. What is left needs the two to pass a code between them through some other channel, and it closes when sessions run in their own container. On a Mac, ssh localhost gives a model a login terminal in the same way when Remote Login is on. That is one more reason vyre uses Touch ID first.

Box identity. tailnet:<login> is an owner device, not a person. Claude on the Mac shares that login. A tailnet caller may read what the owner may read, but it gets no presence.

What presence cannot fix on the box. Claude's sessions run as uid 1000 in vyred's container and network namespace. From there they can:

  • read ~/.vyre, including a file keystore;
  • drive the tailscale CLI as operator;
  • reach the Docker API proxy (docker-api:2375).

The proxy filters endpoints, not bodies. A POST /containers/create with Privileged or a bind of / is root on the host, whatever DockerDriver.create() refuses to build. The floor now denies these by command text. The real fix is separation:

  • Run Claude's sessions as a separate uid with no read access to ~/.vyre, as tailscale's non-operator, and outside the docker-api network.
  • Or put a body-checking proxy that vyred owns in front of the Engine. It should allow only the create body ADR 0009 describes, and exec only into run.vyre.computers containers.

Sessions, for the Deck. Someone revealing or copying items one after another should not need a passkey for every click. presence.session.open is on the floor's list and opens only after Touch ID, a Capsule signature or a passkey. It returns a secret that lasts 5 minutes idle and 30 at most, bound to the tailnet node that opened it. The proof is then x-vyre-presence: session id=<id> secret=<secret>.

Only the floor's SESSIONABLE tools (vault.reveal, vault.copy, vault.totp, and apps.send for a burst of messages from the Capsule, ADR 0022) take it, and only when the tool's presence.session(input) says yes, so a reprompt item never rides a session. A tool-side skip that reads headers is not accepted, because headers are what a model forges.

Accepting a lesson by reply. A lesson changes what Claude is told in every later session, so a forged "yes" is a persistent prompt injection, not only a stricter rule. Learning may accept by reply only when the prompt came from a person. That means an interactive Claude Code prompt, or a threads.send whose surface proved presence. A prompt that an agent or the assistant typed into a thread never counts, and neither does -p input.

Merging. Presence applies to every human-only tool as soon as it reaches main. Any test that starts a real vyred and calls one of these tools as a person needs a verifier: start({ presence: present }) (from test/helpers.js), or a real proof. The security branch already carries those edits for the gate, vault, watchers, switchboard, Capsule bridge, computers, glass, hands, link and CLI tests. A branch that merges after it keeps them, and adds the same line to any new test of that kind.

Addendum, 27 Sep 2026: device keys for the phone apps

ADR 0018 adds a seventh method, device, for the native iOS and Android apps. A phone holds an ECDSA P-256 key in its Secure Enclave or StrongBox, behind a biometric prompt, and enrolls it with presence.enroll {kind: "device", name, public_key, alg: -7} under a passkey or one-time code proof. Its id is the key's fingerprint, as a Capsule key's is, and it has no relying party. A call then carries x-vyre-presence: device key=<id> ts=<ms> nonce=<n> sig=<DER ECDSA> over the Capsule's message, with the same 60-second window and the same single-use nonce set; the Capsule and device checks are one code path. device counts as a strong proof: it is offered on the box, and it opens presence.session.open. It is listed in presence_required methods only once a device key is enrolled. The floor already denies a command that writes any x-vyre-presence header, a device one included.

Addendum, 27 Sep 2026: no proof for the keyboard

The user's rule: Touch ID or a passkey only for pairing, vault secrets, and sending, posting or paying outside. Taking the keyboard of an agent's computer pauses the agent, and handing it back returns what it had, so computers.takeover and computers.giveback leave the floor's list. They and glass.take and glass.release sit on a new list, PERSON_ONLY: no proof, but still a person's. The tools refuse an agent's caller themselves, the registry refuses a guest from another tailnet, and the harness refuses them to Claude's sessions as it does the floor's list. The same holds for a private sign-in (glass.take with private).

Addendum, 27 Sep 2026: the CLI's window, and keystroke injection

The CLI is a first-class surface, so it gets a window like the Deck's session, keyed on the login vyred sees rather than on a secret: the socket peer's pid (the kernel's word), its controlling terminal, the topmost process on that terminal and its start time, and who's login list, after the ancestry check that refuses anything under claude. A tmux pane counts when every client attached to its session runs in such a login with no claude above it.

Threat: keystroke injection. Any process running as the user can type into a terminal the user has open: tmux send-keys into an attached pane, AppleScript do script ... in window 1 into a Terminal tab (no permission prompt when Claude itself runs inside Terminal). The command then runs in the person's login shell and passes every check above. sudo's tty tickets have the same weakness. So the terminal window is narrower than the Deck's:

  • It covers vault.approve and vault.grant only (TERMINAL_WINDOWED): actions that also show in Needs and in notices.
  • Anything that puts a secret or a two-factor code on screen (reveal, copy, TOTP, vyre vault run resolving secrets) and gate.approve (which sends) ask every time from a terminal.
  • Every windowed use writes one line to the terminal it came from ("vyre: used your Touch ID window for ...") and one row to the vault's audit, with the terminal.
  • The Deck, the Capsule and the phone keep the full session: their buttons are not typed into this way. On a box the CLI never gets a window (vyred there sees no host logins); the Deck's passkey proves each call.