Gate answers over MCP
A human gate is a question a run asks a person. This page says who can answer it through Realm’s MCP server, and how. Added after version 0.47.0.
The three values
Section titled “The three values”| Value | What the agent gets |
|---|---|
asked |
The default. In an app that shows forms, the question goes to the person in the app’s own form, and the agent gets no tool that records an answer. In an app without forms, the agent keeps the answer tool, and the app is asked to have the person approve each call. On an HTTP server built per request — realm serve, or a program that builds a server per request or passes statelessHttp — no client gets forms: under asked every client keeps the answer tool, marked for approval. A program that keeps one server per HTTP session sends forms as stdio does. |
offered |
The answer tool, for any client of the server, the agent included. |
withheld |
No tool on the server records an answer, and its replies name no way to answer, except where a workflow’s own text does: a gated step’s instructions (sent as the reply’s gate.agent_hint) and protocol.rules are forwarded labelled as the author’s, and warned about when the workflow is loaded; other step text and quick_start are forwarded as written. |
There are three ways to set the value: the --gate-answers flag of realm mcp and realm serve, the REALM_MCP_GATE_ANSWERS environment variable, and the gateAnswers option of createRealmMcpServer. When more than one is set, the most restrictive value wins: withheld, then asked, then offered. A value that is not one of the three stops the server from starting.
Set the value in your MCP client’s own config, as an argument ("args": ["mcp", "--gate-answers", "withheld"]) or in its env block. A variable you export in a shell may not reach a server your client starts (the MCP SDK’s client starts servers with a reduced environment).
realm mcp and realm serve print the value they run with when they start. Under withheld they also print how a person answers from a terminal: realm run inspect <run_id>, then realm run respond <run_id> --gate <gate_id> --choice <choice>.
Another Realm server or CLI that uses the same store can still record answers. The value holds only for the server it is given to.
The form
Section titled “The form”Under asked, in an app that shows forms, the question is sent to the user’s app as a form the first time start_run, execute_step or advance_run reaches it. The form has one field, “Your answer”, whose options are the question’s choices. Only an accepted form holding one of those choices records an answer. A declined, cancelled or failed form records nothing, and the question keeps waiting.
Realm waits for the form itself. The wait ends at the question’s deadline when the gate declares on_expiry, or at the end of the call’s own time: 240 seconds by default, counted from the start of the call. --form-wait-seconds (REALM_MCP_FORM_WAIT_SECONDS, the formWaitSeconds option) changes it, from 30 to 3600 seconds; when it is set in more than one place, the smallest value wins. The setting is refused at start wherever no form is sent: under withheld or offered, on realm serve, and with statelessHttp.
The tool present_question sends the form again. It is listed only in a session whose client shows forms, and it can send one question’s form three more times in one server process.
The agent never gets an answer call for such a question. Its reply’s next_actions entry has awaiting instead of instruction: the question’s run, gate, step and choices, with words that say whether a form was sent in this session.
In an app without forms, the answer tool carries _meta["anthropic/requiresUserInteraction"], which asks the app to have the person approve each call. Some apps ask the person, some skip it (Claude Code asked in interactive sessions and refused the call in headless runs when this page was written, October 2026); other apps may not ask. Realm cannot see whether anyone approved. A headless client that declares forms but cancels them leaves the question waiting: set offered for such a client.
What an answer records
Section titled “What an answer records”Each answer’s entry in the run record says how it arrived:
| Field | What it holds |
|---|---|
answer |
{ route, shown? }: mcp_form, mcp, cli or slack, as its door declared it; { absent: 'route_not_declared' } from a program that declared none. |
driven_by |
The program that recorded it. |
client_prompt_requested |
true when the app was asked to have the person approve the call. |
principal |
Who the server’s transport authenticated: an API key’s id, realm serve’s shared token (which names no one), or why there is none. |
realm run inspect prints them on the answer’s line, for example:
Answer: approve · answered by (not stated) · through the user's app, as a form (as its door declared), recorded by mcp-stdio, running as ops@server-1 (from the OS user) · no claim token passed (a token is passed only by Realm's MCP answer tool, from the conversation that opened the question, or by a program that calls the library with it)An entry written by an earlier version reads how it arrived was not recorded.
The separation check
Section titled “The separation check”realm mcp --check and realm serve --check print the value the server would start with and where it came from, then a separation check, and start no server; the only programs the check runs are the version, list and check modes of sudo, doas and polkit named below, getent, which looks users and groups up, and id -G, which lists a user’s groups (on macOS, dscl . -read /Users/<user> UserShell, for a user’s shell). Run it from the agent’s own shell: it measures what that login can reach. With HOME set to the home of the user that runs Realm, it checks that user’s store: a store of another user’s that this login cannot open is measured out of its reach, a store this login owns is within its reach, and one that is not found reads unknown (Realm’s store was not found), so the separation check’s first line reads “Separation: unknown”. The agent’s own app settings are read from the login’s own home, never from HOME.
Each condition prints as measured, as configured but not enforced by Realm, as unknown with its cause, or as not measured when the check reads nothing for it: whether another Realm server or CLI uses the store, the service user when none is named, and the economics line when no realm.yaml is given. The store’s time basis prints as a fact, marked (fact). When the realm.yaml the check read names no runner.cache.economics, the economics line says whether this login can change that realm.yaml. A --project folder that is not there reads unknown (no such folder), and a folder with no realm.yaml is named. The separation check’s first line uses the condition lines’ words: it reads “Separation: unknown” when the check cannot tell which separation holds, and its summary says a condition “is unknown” when the check read it and could not settle it, and “was not measured” only when the check read nothing for it. The agent’s sandbox line is read from two settings files in the login’s own home, and says so. The check says an agent cannot record a gate answer only when all of these hold: the store belongs to another user; this login cannot write or read the store, become that user without a password, reach an engine that runs containers or machines as root (Docker, LXD, Incus, Podman, libvirt or containerd), or be in a group that reaches one or the disks, or change the files Realm executes; and --gate-answers withheld is given on the --check line itself. The claim then names what the check cannot read: that no program Realm runs can run code for the agent, that Realm’s server runs the Node binary and Realm package the check ran from, that every Realm server on the machine starts with --gate-answers withheld, and, when the store cannot be read, that no registered workflow’s extension file can be changed by this login.
Becoming Realm’s user
Section titled “Becoming Realm’s user”The check never switches user, never asks for or tries a password, and never opens a login connection. It reads each way from this login to the user Realm runs as, and prints one entry per way, way: reading (how it was read), measured now, with as above in place of how it was read when an entry was read the same way as the one before it:
- sudo. Every root-owned copy this login cannot change, named
sudo,sudo.wsorsudo-rs, once per real file, in/usr/bin,/usr/sbin,/usr/local/bin,/usr/local/sbin,/bin,/sbin,/run/wrappers/binor/run/setuid-programsis askedsudo -V, thenLC_ALL=C sudo -n -l, and, when the original sudo lists its rules,-n -ll. A rule that runs as Realm’s user or root, or with a group that user is in, without a password reads yes, whatever command it is limited to, and so does aDefaultsline that may turn the password off for such a rule. A run-as name counts by its uid, so another name for root or for the user counts too;#uidand%#gidare read as the numbers sudo reads (#0999is uid 999). Rules listed although none of them waives the password read yes too: a cached credential, among other causes, lets them be listed. So doexempt_groupnaming a group this login may be in (its name compared with the names of this login’s groups as sudo compares them), and a listing shown underlistpw=all. In the original sudo’s listing, a rule that needs a password reads “needs” that password: this login’s, or root’s or the user’s underrootpw,targetpworrunaspw, or a named user’s underrunaspw. Underrunaspw,runas_defaultis looked up by name, as sudo looks it up: another user needs that user’s password, and a name no user has, and#0or an all-digit name, which sudo reads as a name, read unknown. Where sudo shows no Defaults — sudo-rs’s listings and either sudo’s refusal — the password may be this login’s, root’s or the user’s, and all three are read. The original sudo’s “a password is required” reads “needs this login’s password or is not allowed”, because sudo does not say which; sudo-rs’s “interactive authentication is required” needs this login’s password, and its “Sorry, user … may not run sudo” reads not allowed. Whenever sudo needs a password, its PAM stacks are read too:/etc/pam.d/sudo, andsudo-iwhere it exists (or, in the original sudo’s listing, the servicespam_serviceandpam_login_servicename), and the servicepam_askpass_servicenames, with their includes. A security-key or fingerprint module adds “a person’s touch or key”; an ssh-agent module, or a stack with no password or person module, reads unknown; apam_wheel.so trustline for this login reads yes where a rule is listed or the sudo-rs refusal names one, and unknown where the original sudo refused. Atimestamp_typeother thanttyorppid, or!tty_tickets, shares one ticket across the login’s sessions, and reads unknown. The check reads sudo’s listing as sudo prints it on each version: a Defaults setting it cannot split reads unknown, unless sudo already reads yes, since a value with a space that ends in a backslash, or holds a backslash before a quotation mark, prints the same as another setting — write such a value without one; so does aDefaults!target holding a space or a regular expression, and a run-as list it cannot split reads unknown. Run-as user names match in any case, as sudo matches them, unless!case_insensitive_useris listed. A run-as group is compared with the user’s groups as sudo reads them, whichid -Glists. A%groupin a rule’s user list is compared as sudo’suser_in_groupcompares it: undermatch_group_by_gid, by the gid of the groupgetentfinds by that exact name (a name it finds no group for does not hold the user, and an all-digit name reads unknown); otherwise by the namegetentgives each of those groups, in any case unless!case_insensitive_groupis listed (a group with no name is skipped, as sudo skips it). A group after the:of a rule’s run-as list is compared by name alone, as sudo compares it: by the namegetentgives each of those groups, in any case unless!case_insensitive_groupis listed, whether or notmatch_group_by_gidis, and#gidas a number.%#gidis compared as a number, and%:group, which only sudo’s group plugin matches, reads unknown. Whereid -Ggives no answer that holds the user’s primary group, a run-as group is read from the groups’ lists instead: unless!case_insensitive_groupis listed, ormatch_group_by_gidfor a%groupin a user list, either of which makes sudo match a group exactly, run-as group names are compared in any case with the user’s primary group and with the groupgetentfinds. Whenid -Gcannot be read, unless!case_insensitive_groupis listed, ormatch_group_by_gidfor a%groupin a user list, a run-as group that does not hold the user by its list or primary group reads unknown, since a secondary group named only in another case is not seen — unless sudo already reads yes, or the rule needs a password, another rule runs as Realm’s user or root, or with a group that user is in, and sudo would ask the same password either way (under the default,rootpwandrunaspw; undertargetpwonly when rules already run as both, and never for a group after the:, whose way asks this login’s password or a listed user’s). Undertargetpwa way through a run-as group asks the password of the user it runs as.sudo -galone runs as this login (sudo 1.8.31 and 1.9.9 allow it for any rule with that group, newer sudo when the login is in the rule’s user list), so this check names this login’s password for every such rule, and also the password of every plain user name the rule’s user list holds. It reads unknown where that list holds a namegetentcannot find, since names match in any case (unless!case_insensitive_useris listed), and where it holds a%group, a+netgroupor a#uidof another user in a rule with a group after the:, that holds neither the user nor root. A user list ofALL, an alias or a negation, or a%groupor+netgroupthat holds the user or root, runs as other users too: for such a rule only this login’s, the user’s and root’s passwords are named. A run-as group that does not list the user is read as not holding it only when thegroupandinitgroupslines of/etc/nsswitch.conf, read as glibc reads them, list local sources (files,compat,dborsystemd, orssswhile SSSD’s socket is absent); another source reads unknown, and so do a second line of either name, a#after the name, a line with no:, sources the check cannot split, and a file with nogroupline while nscd’s socket is there (musl asks nscd). A member found from any source counts. Group membership is read as the system lists it: a member the system does not list is not seen, so when a reading rested on a group’s listed members, the stronger sentence holds only if the members the system lists for each group read are all of its members. - su, never run. su is read toward the user and toward root alike, since root can become the user: each needs that user’s password, and each is read from its own passwd entry and shell. When
getentcannot read a target’s passwd entry, or on macOSdsclits shell, su reads unknown toward it. When a target’s shell runs no command (/usr/sbin/nologin,/sbin/nologin,/bin/falseor/usr/bin/false) and no shells file lists it, each line read asgetusershellreads it (from its first/to a space or a#), su reads not allowed toward it. An empty password field in a target’s passwd entry reads yes. Otherwise su’s PAM stacks,suandsu-l, are read once for both: a password module reads “needs” each target’s password, and apam_wheel.so trustline for this login reads yes. BusyBox’s su, which reads no PAM stack, needs each target’s password, whatever its shell: whether it keeps a shell that runs no command depends on how it was built. - doas. Its rules in
/etc/doas.confand/etc/doas.d/*.confare read, and doas is askeddoas -C <file> -u <user> <command>for Realm’s user and root — for each command a rule names (with one more argument too, unless the rule fixes its arguments) and for a command no rule names:permit nopassreads yes,permitneeds this login’s password, anddenyreads not allowed. - polkit.
pkcheck --action-id <action> --process <pid>is asked, without--allow-user-interaction, for pkexec’s own action and each program pkexec runs where pkexec is present, and fororg.freedesktop.systemd1.manage-units(run0 andsystemd-run) and machine1’s shells (machinectl) where systemd runs as PID 1, since any D-Bus client reaches them. polkit decides by session, and the check’s session may not be the agent’s, so each action’s defaults count too. An answer that asks for a credential reads both this login’s password and an administrator’s, each kept for some minutes: polkit’s rules may change whose it asks. - ssh. It is listed whenever an sshd is present. Its entry says whether this login holds a key — any readable file in its own
.sshbut a.pub,known_hosts,configorauthorized_keys, or a setSSH_AUTH_SOCK— and, when it holds none there, that a key kept outside.sshis not looked for. No connection is made.
A rule the check does not recognise, a file it cannot read, or a tool’s error reads unknown, with the tool’s own line, and then the stronger sentence is not printed, unless sudo already reads yes, or the unknown is only whether a rule that needs a password runs as Realm’s user or root, or with a group that user is in, another rule does, and sudo would ask the same password either way (under the default, rootpw and runaspw; under targetpw only when rules already run as both, and never for a group after the :). A sudo Defaults setting or long-format option the check does not read makes sudo’s reading unknown, unless sudo already reads yes, with sudo’s own line; so does a setting it acts on, listed twice with different values, since sudo does not show which one applies. The tools are asked only as the root-owned copies in the folders above: a copy found only on PATH, or a system copy that is not root-owned or that this login can change, reads unknown; a copy elsewhere on PATH beside a good system copy is never run. A compromise that already holds root can fake any reading.
When every way needs a credential or is not allowed, the stronger sentence adds a condition for each thing no program can see from this login, and only for the ways it found:
- the agent cannot obtain a password a way needs (this login’s, the user’s, root’s, a named user’s or an administrator’s); for all but an administrator’s, that it is not empty, since an empty one sits where this login cannot read it;
- nobody authenticates, as this login where the agent’s processes run, those of sudo, doas and polkit whose password cache a way showed, because each then waives the password for some minutes;
- sudo turns off its password only through
NOPASSWDtags, when the original sudo refused the listing, because its refusal hideslistpw, theDefaults!…andDefaults>…lines,runaspw,timestamp_type,tty_tickets,timestampdirandtimestampowner, and the PAM servicespam_service,pam_login_serviceandpam_askpass_servicename; - no polkit rule grants this login the entry points of the actions asked (pkexec for its own action and each program it runs, run0 and
systemd-runformanage-units, machinectl for machine1’s shells, an action named by its id when none of its entry points is present; polkit’s actions when none is asked) beyond what the check’s own session was shown, when polkit is present: the condition says which rules folder cannot be read here and which is not read; - no person approves a Touch ID, fingerprint or security-key prompt the agent raises, when a way needs a person’s touch or key;
- the user’s
authorized_keysholds none of this login’s keys, when an sshd is present; - the members the system lists for each group read are all of its members, when a reading rested on a group’s listed members.
Condition 3 connects to the sockets of Docker, LXD, Incus, Podman, libvirt and containerd — a socket this login can write counts even while its engine is stopped — and reads whether this login is in the group disk, docker, lxd, incus-admin or libvirt: membership outlasts a stopped daemon.
A refused sudo listing may write one syslog auth line naming the working folder (the original sudo 1.9.15p5 does for a login it lists: sudo: <login> : a password is required ; PWD=… ; USER=root ; COMMAND=list), and may leave an audit record where Linux auditd or macOS auditing runs. For a login sudo does not list, it may also mail root (mail_no_user, on by default, when a mailer is installed). A listing that a still-valid ticket of the original sudo allowed renews that ticket. A socket connect may leave a log line on an engine that logs each connection. Nothing is locked or counted as a failed login while sudo’s noninteractive_auth setting is off, as it is by default. When a listing shows it on, sudo’s entry says so: the check’s own sudo -n -l then ran sudo’s PAM stack, and may count as a login attempt.
On Alpine, /etc/doas.conf (mode 0640) and /etc/doas.d (0750) cannot be read by other users, so doas reads unknown there. They hold rules, not secrets: chmod 0644 /etc/doas.conf /etc/doas.d/*.conf and chmod 0755 /etc/doas.d let the check read them.
On macOS, a sudo rule whose runas is a group, and a pam_wheel line, read unknown, unless sudo already reads yes or, for the group rule, another rule that needs a password settles the reading, as above. Apple’s sudo stack includes sudo_local and a smart-card module, which this check does not read, so sudo reads unknown on a Mac whenever it needs a password.