Skip to content

MCP and the safety gate ​

rta speaks the Model Context Protocol over stdio by default, and over HTTP with --http — see Remote hosting. An MCP client — Claude Code, VS Code, Cursor, Codex, Gemini, Copilot — launches rta mcp serve and gets every capability as a tool, with typed schemas, safety annotations and structured results.

The interesting part is not that it works. It is what an agent can reach before you have decided anything.

Everything in this chapter assumes the agent goes through the server. An agent that can also run shell commands can run rta itself, and what that does to the guarantees is worth reading first.

Registering a client ​

bash
rta mcp install claude

Supported clients: claude, vscode, codex, gemini, cursor, copilot. Anything else that speaks MCP works too — see Connecting your AI tool for the per-client detail, including where each keeps its configuration and how to check afterwards that it worked.

Where a client ships its own command for editing its own configuration, rta runs that. Where it does not, rta prints what to add and where, and stops:

bash
rta mcp install cursor
Add this to ~/.cursor/mcp.json (or .cursor/mcp.json for one project):

{
  "mcpServers": {
    "rta": {
      "command": "/usr/local/bin/rta",
      "args": ["mcp", "serve", "--as", "cursor"]
    }
  }
}

rta does not write another tool's config file ​

This is deliberate, and there are three reasons in descending order of importance:

  • That file is what grants an agent access to your secrets. A tool whose entire argument is that consent should be visible and deliberate has no business writing itself into five agents' permission files unattended.
  • Those files hold things rta must not touch. VS Code's mcp.json is JSONC — comments and all — and often carries API keys in headers. A parse-and-rewrite would destroy comments at best and mishandle a credential at worst.
  • A config format changes when its client changes, not when rta does. The tool that owns the format is the one that stays correct.

--show prints the block without running anything, for any client.

Naming the agent ​

bash
rta mcp install claude --as work-laptop

Every server is named. rta mcp serve refuses to start without --as, and rta mcp install always passes it — the default is the client's name. Without one, every MCP client on your machine would be a single principal sharing one grant file, and worse, one nothing could stop: a lock freezes an agent by name, so an unnamed server had no handle to pull during an incident.

rta grant allow fills the name in when this machine knows exactly one agent, and asks you which when it knows several.

The name is your word, not the agent's. A client announces itself in the protocol handshake, and rta records that claim, but it does not authorize on it — a name a thing chooses for itself is not an identity. What authorizes is the name you typed when you wired the client up.

You will see both in the record: the agent name plainly, the client's self-report in parentheses.

What is exposed, before you decide anything ​

Only read capabilities. That is the default, and it holds with no flags, no config and no decisions.

Safety classWhat an agent needs
readnothing
writea grant a person issued
destructivea grant a person issued
bash
rta grant allow note --ttl 30m           # every write in the note plugin, for half an hour
rta grant allow note.rm --ttl 5m         # one destructive capability, for five minutes

One gate, and a grant is it. There used to be a second: --allow-write and --allow-destructive switches on rta mcp serve, decided once at startup for every call the server would ever make. Two vocabularies competing to answer one question is what made the quickstart's own grant do nothing — the grant was issued, correctly, for a capability the server had never exposed, and the agent was told the tool did not exist. Whichever gate you had learned about, the other one was the one refusing you.

Collapsing them is stronger rather than looser, and it is worth being plain about why. A write used to be reachable for a whole server's lifetime on the strength of one flag typed into a client's config file months earlier, with nothing per call and no expiry. Now it costs a grant a person issued, which the guard can price, a team ceiling can cap, the record shows being spent, and the clock takes back. What was lost is a standing allowlist, and a standing allowlist is precisely what consent should not be.

An agent can now see every capability that is not reserved for you, and call none that changes anything. That is deliberate: a tool it can see and is refused produces a refusal naming the exact command you would run, and a tool that is simply absent produces a model guessing at a different spelling.

A grant on a plugin's capability binds to that plugin's artifact, not to its name:

bash
rta grant list --detail

shows the digest each grant was issued against. Replace the binary behind a plugin and the grants standing on it stop covering anything, which is the rule --allow-destructive hello.wipe@5dae737f8845 used to carry, moved onto the thing that now does the authorizing. Built-ins have no separate artifact to pin — the rta binary you chose to run is the artifact — so their grants carry no digest.

Never a tool ​

A few capabilities are not on offer at any price. grant, agent, lock, operator and pkg — every verb in each — plus audit clients, audit doctor, kv copy, kv edit and the keys verbs that move key material answer to the person at the terminal and to nobody else. They are absent from tools/list on every transport, whatever the flags: an agent that could issue itself a grant, lift its own lock, or read the roster of what your other agents may do would make the rest of this chapter theatre. A call naming one anyway is answered as an unknown tool and written to the record like any other probe. rta explain lists them under never a tool.

The path gate ​

Every path argument must sit under a root. The default root is the directory the server was started in; widen it with --root, which is repeatable.

bash
rta mcp serve --root ~/projects --root /tmp/scratch

The gate governs path arguments only. A capability that opens a fixed file of its own — net hosts list and /etc/hosts — is unaffected, because that path is never an argument for anyone to send.

rta says its roots out loud at startup rather than leaving them to be discovered from a refusal:

rta mcp server listening on stdio
path arguments confined to: /Users/you/projects, /tmp/scratch

One gate ​

Grants are the whole of it: consent for one capability or one plugin, optionally one record, narrowed to one agent and one connection, expiring on its own. rta grant allow note --ttl 8h is the shape for "this agent works on notes today"; rta grant allow kv.get deploy-key --ttl 5m --max-uses 1 is the shape for "this once".

Off by default:

bash
rta mcp serve --consent --consent-notify

With --consent, a call that needs a grant nobody issued is parked instead of refused. You answer it:

bash
rta agent pending
rta agent show 5473aa62        # everything about it, including what it would do
rta agent allow 5473aa62
rta agent deny 5473aa62

A destructive call is previewed before it parks: rta runs the capability's own --dry-run and shows the result on the request, which changes the question from "may this agent call note.rm" to "may it remove this note".

--consent-wait bounds how long a call waits before it is refused anyway (default 90s).

The default is off on purpose. A call parked in a server nobody is watching is worse than a refusal: the agent hangs, you never see it, and the timeout is the only thing that resolves it. Turn consent on when you are actually at the machine — or, for a remote server, when the operator channel gives its enrolled operators a way to answer with --server.

Environment inheritance ​

An MCP server inherits the environment it was started from. If your secret store unlocks without a passphrase in that environment, the server can open it — bounded by grants, but able to.

bash
rta doctor
kv store    info    unlocks from this environment — an MCP server started here
                    can read secrets, bounded only by grants

That line is the whole warning. It is not a misconfiguration; it is a fact about how you set the store up, and it is worth knowing before you connect a client.

The working directory is the client's choice ​

A server also inherits its working directory, and two things are decided by it: the default path root, and where the walk up for a team policy starts. You did not choose that directory — the client did.

So a committed .rta-policy.yaml bounds this agent only if the client happened to start inside that repository. rta says which one it found at startup, beside the roots:

rta mcp server listening on stdio
path arguments confined to: /Users/you/projects
rta: team policy: /Users/you/projects/.rta-policy.yaml

none in force there means the ceiling you committed is not the one applying. rta policy require turns that into a server that refuses to start rather than a line somebody has to notice.

Where the server runs ​

Over stdio, the default, there is no daemon, no port and nothing to start. rta mcp serve is a child process of your MCP client, speaking JSON-RPC over its own stdin and stdout, and it lives exactly as long as the client does. Two clients means two processes:

Two processes, one grant file — which is the whole reason --as exists. Without a name they are one principal, and consent given while talking to the first covers the second.

For one session, or for one task ​

The server is per-session by construction, so the question is really about the permissions, and those have their own clocks rather than the process's:

Bound to a task byWhat it doesWhere
rta grant allow … --ttl 30mConsent that expires on its own, whatever the server doesGrants
rta grant allow … --max-uses 5Consent that runs out by use rather than by clockGrants
rta use stagingWhile it is on, every other environment is refused whatever grants existProfiles
rta grant revoke --allThe end of the task, without touching the clientGrants

Restarting the server changes none of it. That is deliberate: a deadline that ended when a process did would be a deadline your editor could reset by crashing.

In a container, for a hardened server ​

The binary is static and needs almost nothing at runtime — almost, because cert, http, audit web and every plugin that dials TLS (pg, s3, vault, qdrant...) still need a CA bundle to verify against, which a bare scratch image does not have. ghcr.io/this-is-tobi/rta is built FROM gcr.io/distroless/static-debian12:nonroot instead: that CA bundle and the /etc/passwd entry for its nonroot user, and nothing else — still no shell, no package manager, no libc for anything to reach. Published multi-arch (amd64/arm64) with every release, with SLSA provenance, an SBOM and a cosign signature attached to the image digest. Point the client at docker instead of at rta:

json
{
  "mcpServers": {
    "rta": {
      "command": "docker",
      "args": [
        "run", "--rm", "-i",
        "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
        "--network", "none",
        "-v", "rta-home:/rta-home",
        "-v", "${workspaceFolder}:/work:ro",
        "-e", "RTA_CONFIG=/rta-home/config.yaml",
        "-e", "RTA_DATA_DIR=/rta-home",
        "-w", "/work",
        "ghcr.io/this-is-tobi/rta:latest", "mcp", "serve", "--as", "sandboxed", "--root", "/work"
      ]
    }
  }
}

What each part is doing, since a hardening flag nobody can explain is a hardening flag somebody deletes:

FlagWhy
-istdio is the transport; without it the client and the server never meet
--read-only, --cap-drop ALL, --security-opt no-new-privilegesThe server needs none of it, so it gets none of it
--network noneThe strongest setting, and it turns off every capability that reaches the network — audit web, net dns, pg against anything remote. Drop it when you want those
-v rta-home:/rta-homeGrants and the record have to outlive the container, or every restart is a machine with no memory of what you allowed
-e RTA_CONFIG, -e RTA_DATA_DIRNot optional. With no config directory the config path falls back to ./.rta.yaml, and a working-directory file is not honoured — profiles:, plugins: and dashboard: are all ignored, so a plugin would run with its declared defaults
-w /work + --root /workThe path root defaults to the working directory, which in a container is / unless you say otherwise

rta audit clients grades a declaration against this recipe, so none of it has to be checked by eye. A data directory with nothing mounted at it, a missing --root, and any of the three hardening flags being absent all warn — as does pointing an agent at rta-full, where the bundled plugins are trusted at build time and a read needs no grant, so a dozen plugins' reads are reachable with no consent step. Every gate still applies there; what widens is how much sits behind none of them.

Missing RTA_CONFIG/RTA_DATA_DIR is the one graded by which image you run. Against the published narrow image it fails, because that image's environment is defined here and sets neither, so the settings really are being ignored. Against any other image — your own build, the recipe below, a fork — it is only reported: a derived image may set them itself, reading its environment would mean pulling it, and a private deployment building its own image is the ordinary case rather than a suspicious one. --network none is treated as a bonus rather than a baseline, and is the one check that reports its presence instead of its absence: it turns off every capability that reaches the network, so most people running rta for what rta is for cannot use it. Closing the network is confirmed when you have done it; leaving it open earns no row at all, because a row on the ordinary correct setup reads as a deficiency and teaches people to skim past the ones that matter.

The image is the plugin allowlist. A plugin is a separate binary, so a plugin that is not in the image is a plugin the agent cannot reach — no trust decision, no digest, no $PATH to search. Building the image with two plugins in it is the narrowest reach rta can be given.

Which is exactly why ghcr.io/this-is-tobi/rta-full is the wrong image to point an agent at. It carries every first-party plugin and every external tool, so it is the widest reach rta has, and pointing an MCP client at it throws away the one boundary this section is about. It exists for a person at a terminal who wants a console; for an agent, build the narrow image with the plugins that job needs — the recipe is below.

The trade is real and worth stating: a containerized server sees the container's filesystem and network, so fs tree maps what you mounted and nothing else, and git status sees /work. That is the point, and it is also the reason this is not the default.

A team: share the configuration, not the process ​

The want is real and worth stating plainly: a team has environments — dev and staging for app A, staging and production for app B — everyone has their own agent, and nobody wants to configure the same six profiles on eight laptops. What people reach for is one shared MCP server everyone points at.

Share the image instead. A profile is written by a command, so it can be baked in at build time, and every member starts with the environments already there and nothing to configure. Two things go into the image and neither goes under the state volume: the plugins with their trust, into rta's read-only system root, and the profiles, into a config file the image carries. The state volume each member mounts on /rta-home then holds only what is theirs — grants and the record — and hides nothing the image put there:

dockerfile
FROM alpine:3.20 AS setup
COPY --from=ghcr.io/this-is-tobi/rta:latest /usr/local/bin/rta /usr/local/bin/rta
COPY rta-plugin-pg /usr/local/bin/
ENV RTA_CONFIG=/etc/rta/config.yaml
RUN mkdir -p /etc/rta && install -d -m 0700 -o 65532 -g 65532 /rta-home && \
    RTA_DATA_DIR=/usr/local/lib/rta rta plugin trust pg --yes && \
    chmod -R a+rX /usr/local/lib/rta && \
    rta profile set app-a-staging --note "app A, staging" --ttl 8h \
      --plugin pg --set database=app-a \
      --kube staging/app-a/svc/postgres:5432 \
      --secret password=kube:postgres-creds/password && \
    rta profile set app-b-prod --note "app B, production" --ttl 1h \
      --plugin pg --set database=app-b \
      --kube prod/app-b/svc/postgres:5432 \
      --secret password=kube:postgres-creds/password

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=setup /usr/local/bin/ /usr/local/bin/
COPY --from=setup /usr/local/lib/rta /usr/local/lib/rta
COPY --from=setup /etc/rta /etc/rta
COPY --from=setup --chown=65532:65532 /rta-home /rta-home
ENV RTA_CONFIG=/etc/rta/config.yaml RTA_DATA_DIR=/rta-home RTA_SYSTEM_DIR=/usr/local/lib/rta PATH=/usr/local/bin
ENTRYPOINT ["/usr/local/bin/rta"]

The setup stage needs Alpine's shell to run rta plugin trust/rta profile set at build time — it never ships. rta plugin trust writes into whatever RTA_DATA_DIR names, so pointing it at /usr/local/lib/rta for that one command is what puts the trust record beside the plugin, in the root the run time reads as RTA_SYSTEM_DIR and a volume cannot mask. /rta-home ships empty and owned by nonroot, so a fresh named volume mounted there starts out writable by rta, the same way the published images arrange it. The final stage starts over from the same distroless base the published image uses, for the same reason: pg over TLS needs a CA bundle to verify against, same as the primary recipe above. The full image, ghcr.io/this-is-tobi/rta-full, is built this way too — every first-party plugin installed from the official index into that same root.

Each member wires their client to docker run on that image, exactly as in the recipe above, mounting their own ~/.kube and their own state volume. What the image carries is a reference, never a value:

yaml
secrets:
  password: kube:postgres-creds/password

So the image is safe to publish to your internal registry. The credential is read at call time from the cluster, with the caller's own kubeconfig and their own RBAC — the access control your organisation already runs keeps applying, per person, unchanged. The same is true of the kube: forward: reaching the database at all requires that member's cluster access.

That is the whole of "without any config", and everything else stays where it belongs. Grants are theirs. The record says what they did. rta use bounds their agents. Nothing is shared that a person has to be accountable for.

Add .rta-policy.yaml to the repositories they work in and the team also gets a ceiling — committed, travelling with a clone, needing no seal because it can only ever subtract.

Why not one server for everyone ​

Because "a shared server that can reach everything anybody is authorised for" is a single process holding the union of every environment's credentials, reachable by every member's agent. Concretely, it costs you five things:

What breaksWhy
Per-person access controlThe server authenticates as itself. Your cluster RBAC, database roles and cloud IAM stop distinguishing between eight people and start seeing one service account
The recordrta logs the agent name a person typed on their own machine. On a shared process every client is a client of the same process — two members' agents both log as claude, and nobody can answer who ran pg dump
Live consent--consent parks a call and waits for the person at the machine. On a shared server, which person? Whose desktop notification rings, and who is accountable for the answer?
rta useIt exists to subtract — switching to staging takes production away from every agent. Shared, one person switching takes it away from everyone, silently
Blast radiusOne compromised agent on one laptop reaches the union of every environment, because the union is what the server was configured with

The transport was the smaller problem, and smaller than it first looked: Remote hosting, below, is --http — authenticated over the wire instead of by parent-process trust, so "who is on the other end" has a standard answer. What it does not change is anything in the table above — authenticating five people to one process that holds the union of their environments tells you which of them is calling and leaves every other row exactly as it is.

None of that is an argument against rta running somewhere other than a laptop. Run it in your dev platform, in a Codespace, in a per-user pod — one instance per person, authenticating as that person, built from the shared image. That is the same convenience with none of the collapse.

Remote hosting (HTTP) ​

bash
rta mcp serve --http 127.0.0.1:8443 --token-file tokens.txt --as work

A second transport, opt-in: the server listens on TCP instead of speaking stdio to a parent process, which is what running it somewhere other than your own machine — the shape the previous section argues for — actually needs.

Putting that somewhere is its own subject: Kubernetes is the deployment, with a chart whose unit is the instance rather than the release.

A caller now has to prove who it is over the wire, since there is no parent process left to trust instead. Two mechanisms, usable together:

FlagProves
--token-file <path>A static, operator-issued token — one label token pair per line in a file only the operator can read; world-readable files are refused, and so is a token shorter than 16 characters (rta gen token makes one)
--oidc-issuer, --oidc-audience, --oidc-subjectA real identity provider's token, for one of the named subjects. An issuer and audience alone identify an application, not a person, so at least one --oidc-subject is required — OIDC is the full setup, including the Keycloak audience mapper without which every token is rejected

A rejected token is answered slower from the same address after five failures in a minute, doubling up to two seconds: a guess a second becomes a guess every two, and an operator who mistyped once never notices. Behind a reverse proxy every client shares the address, so a guessing attacker slows the operators beside it for as long as the guessing lasts — that trade is taken rather than trusting a Forwarded header the attacker writes.

--http refuses to start with neither configured. --consent over --http additionally requires --operators — a parked call waits for a person, enrolled operators answering over the operator channel are the only people positioned to be that person, and a control nobody can exercise must not be allowed to pretend it works.

TLS is not this process's job. Bind to a private address and put a reverse proxy, ingress or service mesh in front of it for termination. A bind that other machines can reach — 0.0.0.0, :8443, an interface address — is announced at startup, because on that transport the token is the whole credential and it crosses the wire as it is.

Every request's verified identity is recorded a third way, beside --as and the client's own self-report: rta agent log shows which credential actually authenticated each call — a token's label, an OIDC subject — so more than one credential valid for an instance stays distinguishable instead of collapsing into one indistinguishable principal.

Probes and counters, on a second listener ​

A hosted server needs to tell an orchestrator whether it is alive and whether it is ready, and a monitoring stack in the same cluster has no node_exporter to read the counters out of a file with. --observe binds a second address for both:

bash
rta mcp serve --as work --http :8443 --token-file tokens.txt --observe :9090

It is deliberately not more paths on the --http listener. Bearer authentication wraps that one whole, and adding open paths beside the protocol handler would turn a property of the wrapper into a property of route matching — where every handler added later is a chance to match wrongly. Kept apart, an operator can also bind this where the agent-facing port is not: loopback, or a pod port the Service never publishes.

PathCredentialSays
/liveznonethe process is serving. It consults nothing on purpose — a liveness probe wired to the store asks for a restart that meets the same broken volume
/readyznonethe record can actually be written. A detached volume or a full disk leaves a server that still accepts connections and authenticates callers while failing at the one thing it is for
/healthznonethe same as /readyz, for tooling that asks by that name
/metricsthe same bearer token as MCPthe exposition format rta agent metrics prints

/metrics is authenticated because the counters name which agent called what, and how often it was refused — a map of the machine's activity, not a health signal. Binding it somewhere private is the outer control and the token is the inner one; a Prometheus scrape config carries a bearer token without complaint, so keeping both costs nothing.

The operator channel ​

A remote server closes the agent out of grant allow — and closes you out with it: its grant roster lived behind whatever infrastructure access reaches the machine. The operator channel is the way back in that an agent cannot use.

bash
# on your machine, once
rta operator init                     # mints your key; prints the line below
# on the server, in a file only its owner can write
tobi 4Jx…base64…Qk=                   # one "label base64-pubkey" per line
# start the server with it
rta mcp serve --http :8443 --token-file tokens.txt \
  --operators operators.txt --operators-url https://rta.example.com

--operators-url is the server's canonical identity — the exact URL operators write in their remotes.yaml — and it is signed into every operator request. That is the anti-relay binding: a hostile server you also talk to could present another server's challenge as its own, but the envelope it collects names the server you were actually addressing and verifies nowhere else.

--operators mounts /operator/v1 beside the MCP endpoint. Name the server in remotes.yaml beside your config —

yaml
servers:
  work:
    url: https://rta.example.com

— and the existing verbs grow a --server flag. rta grant list --server work reads that server's roster; rta operator status --server work asks who it is (version, agent name, guard state, enrolled operators); rta grant revoke kv --server work takes authority back, with --dry-run previewed by the server's own store rather than guessed at from here; rta agent pending --server work reads its parked queue, and rta agent allow/deny answer it. Each call names its target, because an ambient "current server" is how a staging command lands on prod.

Issuing remotely takes one more provisioning step, because a grant is authority and authority needs a signature the server will honour. On the server, once: rta grant guard remote operators.txt --url https://rta.example.com enrolls the roster's keys as the machine's guard, bound to its canonical URL — after which a grant is honoured only when an enrolled operator signed it for this server, and rta grant allow at the server's own shell has no key to unlock, by construction. The binding is what keeps a fleet sharing one roster from becoming one trust domain: a grant signed for staging verifies on no other machine, however its bytes travel. Then, from your machine:

bash
rta grant allow kv.get db-password --ttl 15m --server work

The server prepares the grant — validation, TTL clamping against its policy, profile pinning, attribution — under its own config and catalogue; your rta then checks the draft against what you asked before anything is signed, field by field, with the server licensed only to clamp the lifetime downward — a compromised server must not be a signing oracle for authority nobody requested. Your passphrase unlocks the operator key; what survived the check is signed byte-for-byte and submitted; and the stored row carries operator:<label> in its Origin column, so a multi-operator server's listing names who issued what. The server re-checks everything on submission — attribution against the caller the envelope proved, untouched consumption bookkeeping, clock skew, expiry, both TTL ceilings — and the guard's own load-time enforcement then verifies the signature and its server binding on every read, like any other guard-signed row.

Live consent travels the same way, and it is what makes --consent legal beside --http at all: start the server with both plus --operators, and a call that parks waits for an enrolled operator rather than for nobody. rta agent pending --server work lists the queue, rta agent show <id> --server work reads one call in full, and rta agent allow/deny answer it — every answer signed under your passphrase, the one-shot included. That last part is a deliberate asymmetry with the local flow, where a bare agent allow is passphrase-free because it releases a call an agent with a shell could have run directly: that shell-equivalence argument does not travel a network, so remotely there is no passphrase-free answer. The binding is the digest the local flow already rests on, made to cross the wire: your machine derives it from the fields it displayed — never copies it from what the server sent — and the server compares it against the parked file at the moment the sealed decision is minted, so a queue entry that changed after you read it, or a server that showed you one call while parking another, produces a refusal instead of an approval. (--ttl stays out of a remote answer: a standing grant is the prepare-and-sign flow above, with its own review step.)

What makes this channel one an agent cannot ride: every call is an ed25519 signature over the server's canonical URL, a single-use nonce the server just issued, the verb and its payload — and the signing key exists on your machine only inside a passphrase, the guard's own mechanics pointed outward. The passphrase arrives through a prompt or the TUI's masked field, never from the environment, and is refused on the command line; so an agent that reads every file you own still cannot sign, a captured request replays nowhere and verifies on no other server, and an agent's bearer token opens nothing here — the two mechanisms never meet. The server, for its part, holds only public keys: compromising it forges no operator's hand.

Everything the channel changes is written into the record beside the agent's own calls: a revocation, an issued grant, an answered consent, a lock placed or lifted — one line each, operator. in front of the verb, attributed operator:<label> in the credential column, refusals included. The record that shows a parked call approved therefore also shows who approved it, from which enrolled key. Reads stay off the record: a watching dashboard polls status every few seconds, and recording polls would churn real history out of the record's retention.

The roster is the token file's kind of trust anchor and gets the same treatment: rta never writes it, weak permissions refuse startup, and it is read once — a rewrite behind a running server's back changes nothing until the next deliberate restart. Plain http:// in remotes.yaml is refused for anything but loopback, and for the OIDC issuer's reason: the signature protects what you send, TLS protects what you read — a grant listing rewritten in transit is decisions made on a lie.

A roster line is label base64-pubkey — the exact line rta operator status prints on the operator's own machine — optionally annotated role=read and/or expires=YYYY-MM-DD. A read-only key answers status, grant.list, consent.list and lock.list and nothing else: no revocation, no issuance, no consent answers, and grant guard remote never enrolls it as grant-signing trust, so even its stolen key mints nothing. The intended occupant is a component rather than a person — a status page or dashboard watching the queue and the grants under its own key, with a blast radius of reads. A bare line stays what it has always been, a full operator; and anything unrecognized in the annotation position refuses the whole file, because a typo that silently meant "full" is the one failure a restriction must not have.

expires= turns a departure everyone knows is coming — a contractor's end date, a component being retired — from a memory problem into a clock problem: the key stops working when that day arrives, checked per call against the running server's clock, so this is the one roster edit that needs no restart to take effect. It only subtracts. The row still shows on the status page after its day, marked expired, because that row is a chore: deleting the line is still the real eviction, and an already-expired key stays out of what grant guard remote would enroll. A date rta cannot read refuses the whole file, same as any other annotation typo. For a departure nobody saw coming, that is not this — that is a lock.

Locks: the instant no ​

Expiry and revocation both leave a gap that only shows during an incident: revoking every grant still leaves a misbehaving agent's bearer token opening the ungated read tools, and a compromised operator key stays enrolled until someone edits the roster and restarts — the roster is deliberately read once. A lock is the instant path:

bash
rta lock add claude --note "runaway loop, ping me"       # on the machine
rta lock add dash --kind operator --server work          # or from your machine, signed
rta lock list
rta lock rm claude

A locked agent or credential is refused on every tool call before any other gate (the protocol's own handshake and catalogue listing still answer — nothing executes through them) — never parked as a consent question, because a lock is the "stop asking me" control — and a locked operator label gets no verb on the channel at all. Running servers pick a lock up on their next request, no restart, and the note travels to the locked party on every refusal. Locks only subtract, so placing one asks for no passphrase: revoking never asks, and an incident is the wrong moment to demand a secret. --ttl 2h makes one lift itself; without it a lock stands until rta lock rm.

Two edges worth knowing before you need them. First lock wins: a locked operator cannot unlock anyone, themselves included, so a fully locked-out roster is recovered at the machine's own terminal — where rta lock always works, because the person standing there is the authority locks answer to. And the lock file is sealed like the grant file, with the guard's failure direction: a running server that saw locks keeps enforcing them even if the file is deleted out from under it, so the rm that would quietly restore access restores nothing for the process the attacker is talking through. Lifting a lock is rta lock rm, on the machine or as a signed operator call — never a file deletion.

Locking an operator freezes the key, not what it already signed — pair it with rta grant revoke for anything that key issued. It silences the key's verbs, not its ink: a locked key's mutation attempts keep landing in the record as refusals, which is the evidence trail working — and also why a key you believe compromised is one to remove from the roster (edit the --operators file, restart), not merely to lock forever: enrollment is what lets it make the server write anything at all. And rta lock is on the harness deny list rta audit clients --fix prints, for the expanding half: an agent that could run lock rm would be unfreezing itself.

sys, fs, git, keys.list, kv.status, the two audit checks that grade a project on this disk (audit.deps, audit.why), and the parts of net that read or change this host's own network configuration (net.info, net.hosts.*, net.resolver.*) answer for the machine rta happens to run on. Over HTTP those are never registered as tools at all — absent from tools/list, not refused when called — because a remote caller is never this machine. rta mcp serve --http says so at startup:

rta mcp server listening on http://127.0.0.1:8443
rta: every request needs a bearer token; TLS is not this process's job — put a reverse proxy, ingress or service mesh in front of it
rta: remote transport hides 28 capabilities that describe this machine: audit.deps, audit.why, fs.hash, fs.tree, … (28 total)

Everything else in net — ping, dns, trace, probe, send, port — stays reachable, since those describe a caller-named target rather than this host. A result still reflects the vantage point of wherever rta is actually running, which is worth knowing rather than assuming.

What is still true, and what stops being true ​

The credentials-move trade the container recipe above rests on — "the caller's own kubeconfig, the caller's own RBAC" — needs restating here rather than assumed. A container on your own machine still has your kubeconfig mounted into it; a real network call has no caller-side credential at all, only whatever the server's own ambient identity is. Provision that identity as deliberately as any other production credential.

The kv store is exactly as strong remotely as locally, no stronger — "unlocks from this environment" (rta doctor) is equally true of a laptop and a gateway, with no hardware-backed second factor either way. Prefer a passphrase over a plaintext identity file sitting on a host other people can reach.

Plugin confinement (rta doctor's "plugin confinement" row) is sandbox-exec on macOS and nothing on Linux — a deliberate, documented gap rather than an oversight, and Linux is the realistic OS for a remote gateway. A hardened deployment supplies its own process sandboxing there — containers, seccomp, a read-only root filesystem, an egress allowlist — since rta contributes none of its own on that platform.

Consent now has exactly one place to go. --consent combines with --http only when --operators names a roster, because answering a parked call needs a channel to reach a person, and the operator channel is the one built for it — a signed answer from an enrolled operator's own machine, never a bearer credential an agent could ride. What has not changed is the multi-user arithmetic: one queue, several operators, first answer wins, and the accountability question the cost table raises for a shared server is answered only as far as the decision file naming which operator signed — the rest of what a shared server would take still stands.

--network none in the container recipe above was only ever safe because stdio needs no network at all. A listener needs an inbound path: publish the container's port to wherever the reverse proxy in front of it reaches, and keep outbound scoped to what the enabled plugins actually call — not open, and not none.

Next ​