Roles, consent and the guard
Grants is the everyday page: issuing one, the four bounds, seeing and taking them back. This page is what a team or a careful person adds on top: a role that issues a day of grants under one word, live consent for when you would rather be asked than refuse, the file the grants live in, and a passphrase in front of issuing them.
Roles: a day of grants under one word
A grant is one target. A day of work is a dozen, and with the guard on that was a dozen passphrases. A role is the list, written once, and grant issue is that list issued to one agent under one prompt:
# ~/.config/rta/config.yaml — yours
roles:
dev:
ttl: 8h
grants:
- kv.get db-password
- pg.query --profile staging --rate 100/1h
- noterta grant roles # what this machine can issue, every line of each, and the window each will really get
rta grant issue dev --agent claude # every line, one passphrase
rta grant list --role dev # what stands under it
rta grant renew --role dev # move the whole bundle's deadline, one passphrase
rta grant revoke --role dev # take the whole bundle backEach line is in the grammar rta grant allow takes — a target, an optional record, and --profile, --ttl, --max-uses, --rate or --note — and is built and checked exactly as a typed grant is: the target has to exist and need a grant, the ceiling caps and forbids each line, the guard signs each grant, a lock still overrides all of them. The grants last --ttl, else the role's ttl:, else twelve hours; a line with its own --ttl keeps the shorter. grant list shows the role on each row.
The bundle has no id of its own. It is "role dev, issued to claude", and that pair already names it: the grant file keeps one row per target, record, connection and agent, so issuing a role whose lines already stand refreshes them rather than doubling them. A line that replaces a grant you issued by hand — with an hour left, say, under the role's twelve — is a widening you did not type, so the receipt says what each line replaced, before the passphrase asks for anything. revoke --role says what still stands afterwards, computed rather than promised.
A role is not something an agent holds. The agent still presents its --as name and the gate still reads grants; the role name on a grant is your bookkeeping, signed with the rest so a grant cannot be re-filed under a role it was not issued under. Revoking a role is not a lock: the agent may be granted things again a minute later.
A role from the team's file is somebody else's list. .rta-policy.yaml may carry roles: beside the ceiling that bounds them, so a team shares the bundle and the limit in one committed file. That file grants nothing by existing — a person issues the role, at a terminal — but an edit could widen a role a person then issues with one word. So a team's role is issued at the command line only, where its lines are printed before the passphrase asks for them, and with the guard off it wants --yes after rta grant roles <name> has been read. A line that names a whole plugin, kv among them, is every capability of it, so rta grant roles <name>, the lines printed before the passphrase, the dry run and the receipt each say how many capabilities it covers, which are destructive and which reveal stored values, in the words rta grant allow kv uses; the receipt of a grant issued on a server with --server says it too, as that server counts it. Your own roles, in your config or your policy file or a file you named with RTA_POLICY, ask nothing extra: you wrote them. The same name in two files is refused rather than resolved by precedence, and rta doctor says so ahead of time.
Everything an environment covers, without writing the role
A role is a list somebody wrote. When the list is "everything this environment covers", grant issue reads it for you:
rta grant issue --profile staging --agent claude # its reads, on every connection
rta grant issue --profile staging --agent claude --safety write # and its writes
rta grant revoke --profile staging --agent claude # the whole bundle backGive a role or --profile, never both and never neither. The grants are the ordinary ones: one capability on one connection each, built and checked as grant allow builds them, listed before the passphrase and signed under one. They last --ttl, else twelve hours.
It is expanded when you issue it, and never stored as a pattern. A grant row binds one capability to one connection and one plugin build, with no wildcard for any of them, because a stored "everything staging covers" would follow the name to whatever the environment holds next month — a plugin it gains, a connection it is repointed at. So the environment is read once, when you ask, and what is signed is the rows. A plugin added to it tomorrow is covered by nothing until you issue the bundle again. An environment holding two connections to one plugin, pg and pg/analytics, is two rows of each capability, each pinned to its own connection, and every connection is in the listing.
Reads unless you say more. --safety read is the default, write takes in the writes and destructive what destroys, each class taking in the ones before it; the class is how far the bundle reaches and never a target. A capability that reveals a stored value is classed a write, so the default bundle hands over no secret, and --safety write names each one beside what it means with no record named: every record it reaches. The receipt names every destructive capability too, and what the next class up would add.
What the ceiling forbids is skipped, and named. A role is somebody's list, so one forbidden line refuses the whole role. Here nobody meant any one row, so a capability the team's ceiling forbids on a connection — never, neverProfile, or requireScope, which a row naming no record cannot satisfy — is left out and listed with the rule and the file, and the rest is issued. Only a bundle with nothing left is refused, in the ceiling's own words.
What it cannot cover, it counts. A capability for the person at the terminal is never a tool, and one that takes no profile cannot be a grant on a connection; both are counted in the receipt, and the ones among the second that need a grant of their own are named, since those are what somebody may think the bundle gave. A plugin the environment configures and this machine has not installed is said too.
--max-uses and --rate bound each grant on its own, not the bundle, so ten uses on a bundle of twenty is up to two hundred calls, and the receipt says so beside the number.
The grants carry no role, so the bundle is named by what it is: the agent's grants on that environment. rta grant revoke --profile staging --agent claude takes them back, every connection of the environment included, and rta grant renew with the same two moves their deadline — and both take whatever else that agent holds there, however it was issued, which the receipt's last line says with the exact commands. Issuing the bundle again replaces the grants it matches, and the receipt says how many were standing and where they came from.
It is local. The operator channel signs one grant per envelope, and a bundle is read from the config and policy of the machine it is issued on, so grant issue takes no --server, for a role as for an environment. To issue to a remote server, grant allow --server issues one grant at a time.
Live consent, when you would rather be asked
Off by default, and an option of the server, so it goes into the registration:
rta mcp install claude --consent --consent-notify(rta mcp serve --consent --consent-notify is the same two flags for a server you start yourself.) With --consent, a call that needs a grant nobody issued is parked instead of refused. You answer it:
rta agent pending
rta agent show # everything about the parked call, including what it would do
rta agent allow # shows the call, asks `Allow once? [y/N]`, and runs it on a y
rta agent denyWith one call parked the id is optional at a terminal: rta agent allow shows what the call is and asks, and anything but y or yes — an empty line, n, a closed input — leaves it parked and says how to deny it. With several parked, show, allow and deny list them and answer none: name one, as in rta agent allow 5473aa62. Where there is no terminal, under --yes or in a script, the id is always required, so a script never answers a call it did not look at.
A call to a capability whose answer is a stored value, such as kv.get, says so on the page and on the card above the question, in a reveals row: the stored value itself, not a masked copy, which becomes part of the agent's context. The row comes from this machine's own catalogue and not from the request, which the agent's server wrote.
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". The preview is not optional, and it is bounded to built-in capabilities, whose dry runs are cheap and honest about DryRun by test — a plugin's handler is never run to answer a question about it.
Answering allow runs that one call. It does not create a standing grant — if the agent asks again, you are asked again. That is the difference between consent and permission, and rta keeps them separate.
rta agent allow <id> --ttl 1h also issues the grant the call was missing, for the record it named and no wider — a grant for each record when it names several, as a kv.rename names the key and where it goes. A call naming a record ending in / — a folder to a grant, https:// included, and the agent chose it — is released on its own and no grant is issued; the answer names the rta grant allow calls that issue them on purpose, if every record under the folder is what you mean. A call naming a record that is only white space is released on its own too, since no grant can name that record: grant allow refuses it, and grant revoke and grant renew could never take one back by it. The team's ceiling holds the answer record by record, as it would hold each grant. On a plugin's capability the grant is bound to the plugin's build as it is now, exactly as grant allow binds it, so the next call within the window runs; the answer refuses to issue one, and says so, when rta cannot say which binary answers for the plugin.
--consent-wait bounds how long a call waits before it is refused anyway (default 90s). The agent is then refused as core.consent.expired, told that the operator did not answer within the wait and to retry if they are at the machine, and the record says nobody answered within 90s. --consent-notify rings the desktop with the capability and rta agent allow, never an id: it tells you what is waiting, and the command to run does the rest.
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.
The same three commands take --server <name> to answer a call parked on a remote server, as a signed call over the operator channel — where, unlike here, even the one-shot answer costs your operator key's passphrase, because "an agent with a shell could have done this anyway" is true at your terminal and false across a network.
The file, and why it is sealed
Grants live in ~/.local/share/rta/grants.json, and the file carries a tamper seal.
The seal stops a forged line from a process that cannot read the key. It does not stop an agent that can run rta grant allow itself — which is why every grant also records whether anybody was at the terminal when it was issued.
The reason is asymmetry: a forged line in a grant file adds permission. Anything that can write that file can write itself an allowance, and rta would honour it. So the file is sealed, and a grant file that does not verify is not honoured — rta doctor says so plainly rather than failing quietly.
This is exactly why a team policy ceiling needs no seal: it can only ever subtract, so the worst a hostile edit achieves is making rta refuse more.
The guard: a passphrase in front of issuance
The seal's limit above has an answer, and it is opt-in:
rta grant guard onFrom then on, issuing or renewing a grant asks for a passphrase — including rta agent allow --ttl, which also mints one. A passphrase being set, here and for the operator key, needs at least eight characters, counted as characters and not bytes, because the key it wraps sits in a file that can be copied and a short passphrase then falls to guessing offline; there is no flag to waive it, and a key wrapped under a shorter one before the rule keeps opening. Every grant is signed with a key that exists only encrypted under that passphrase, and a grant without a valid signature is not honoured. The difference from the seal is one of kind, not degree: the seal's key sits on disk where anything running as you can read it, while the guard's passphrase lives in your head. An agent that runs rta grant allow from its shell is refused, however it invokes the binary — the ordinary self-granting path moves from detection (the Origin column, after the fact) to prevention.
Honest edges, stated rather than implied:
- Enabling and disabling both clear the grant file. Grants issued without a passphrase would be laundered by blessing them wholesale, and signatures with no guard beside them read as tampering. Grants last a day at most; re-issuing costs minutes.
- Revoking never asks. Taking authority away is the fail-safe direction, and an incident is the wrong moment to demand a secret.
- A forgotten passphrase costs at most a day.
rmthe guard state,rta grant revoke --all,rta grant guard onwith a new passphrase — loud, bounded, and no secret is ever recoverable from disk. - File tampering stays in the detection regime. Something running as you can still delete the guard's state or swap its key; every such rewrite rta can notice is refused loudly and fails closed. The cheapest rollback is worth naming: deleting the guard state and the grant file together leaves a machine indistinguishable from one where the guard was never enabled. A running MCP server pins the guard state at startup and refuses every grant-gated call if it weakens mid-session — which covers exactly the session the attacker is talking through. Across restarts nothing on disk can testify, and the boundary chapter owns what remains.
- One-shot consent answers stay passphrase-free.
rta agent allow <id>without--ttlreleases exactly one already-parked call — something an agent with a shell could have run directly — and every such call is in the record. The guard prices authority that outlives the conversation; the harness deny list fromrta audit clients --fixis the layer that stops an agent answering its own questions at all. - The passphrase never travels on the command line.
--passphraseis refused from the CLI — argv is readable by every process you run and lands in shell history — so the channels are the prompt and the TUI's masked field, both of which land nowhere. Because no run takes it,--help, shell completion andrta explaindo not list the flag; typing it anyway is refused with the way to answer instead. - There is no environment variable for the passphrase, and there will not be one. The kv store accepts
RTA_KV_PASSPHRASEbecause some setups need unattended unlocks, andrta doctorwarns about the inheritance. The guard exists for the opposite trade: issuance is rare, attended, and nothing an agent inherits may satisfy it.
rta grant guard status says whether it is on, since when, and under which key; rta doctor carries the same fact.
Remote mode: a guard whose keys are elsewhere
For a machine whose humans are not at its terminal — an rta mcp serve --http gateway — the guard has a second shape: rta grant guard remote operators.txt --url https://rta.example.com enrolls the public keys from an operator roster (minus any role=read rows — a watching key is not a signing key), bound to this server's canonical URL, and from then on a grant is honoured only when one of those keys signed it for this server — the URL sits inside the signed authority, so a fleet sharing one roster stays many trust domains: a row signed for staging is refused on prod however its bytes travel. No key material lives on the machine at all: nothing to steal, no passphrase to phish out of a server process, and rta grant allow at its own shell finds nothing to unlock — refused by construction, which on a remote server is the boundary completing itself rather than a gap. Issuance happens from an enrolled operator's own machine over the operator channel, each grant signed there under that operator's passphrase and attributed to their roster label in the listing's Origin column. Turning remote mode off asks for no passphrase — there is none here to ask for — so it costs presence at the machine's terminal, and clears the grants the operators signed, the same clean-slate rule as every other guard transition.
Related
- Grants — the everyday page
- Connect an agent — consent needs a server you started with
--consent
Next
The record — what agents actually did with what you granted.