Capability Gates
A capability gate decides whether one action reaches a host. It decides on the consequence of the action, and never on the text of a command.
Read this page before you upgrade a deployment that runs remote commands. The shipped default policy refuses every unattended remote action.
The policy lives in the gates block of config.json. For every key and every default, see configuration-security.md#capability-gates.
What the Gate Covers
The gate answers one question for one action: may this action run now?
Three inputs decide the answer:
- The capability class of the tool.
- The scope of the action, which is the number of hosts it reaches.
- The execution context of the turn, which states whether a person waits.
The executor process holds the gate. The agent process holds no credential and no transport, so a compromised agent can ask and cannot act. See architecture.md#process-split.
Capability Classes
Every tool declares one class. Policy keys on the class and never on a tool name, so a new tool inherits a decision instead of an exemption.
| Class | Members | The gate |
|---|---|---|
read | read_file, list_dir, find_files, grep, web_fetch, web_search, list_servers, get_server, get_diagram, a data connector's read operations | no decision |
mutate.local | write_file, edit_file, apply_patch, exec, cron, create_diagram, update_diagram | no decision |
mutate.inventory | create_server, update_server, delete_server | one decision per write |
mutate.remote | execute_on_server, a data connector's write operations | one decision per action |
credential.access | the resolution of a secretRef to a plaintext credential | see below |
A tool that declares no class resolves to mutate.remote. The loader finds tools by a package scan and by entry points, so an omission must cost the most restrictive class.
An MCP server's tools declare nothing, so every one of them resolves to mutate.remote. A listing and a send land in one class. A grant that unblocks the first unblocks the second. A data connector declares a class per operation instead, so its reads and its writes take different decisions.
mutate.inventory is separate from mutate.local on purpose. update_server replaces config and secretRef in full. One write can therefore keep a name and point it at another address, which changes what a later remote action reaches.
No agent tool returns a secret value. The executor resolves a secretRef inside its own process, and it writes one log line at the GATE level for each resolution. The credential.access keys in config carry the policy for that class.
Scope Tiers
Scope answers one question: how many hosts does this action reach? It is independent of the class.
| Scope | Meaning |
|---|---|
host | exactly one host |
group | a pattern that resolves to more than one host |
all | an unbounded pattern, the literal all, or a wildcard |
For the one value the schema accepts at all scope, see configuration-security.md#policy-keys. The executor also refuses an all-scope action before it reads policy:
The pattern names an unbounded host set, so its scope is `all`. No policy permits
`all` scope, and no approval path exists for it.
The resolver derives the scope from the resolved host set, and never from the word in the config:
| Provider | How the resolver names the hosts |
|---|---|
ssh | the host field, so the scope is always host |
ssm | the instanceId field, so the scope is always host |
api | the hostname of baseUrl, so the scope is always host |
ansible-runner | it expands inventoryHost, or group when the first field is empty |
For an ansible-runner server the resolver prefers the ansible-inventory binary. It falls back to the inventory parser in the repository when the binary is absent. An unbounded pattern keeps scope all even at one resolved host, because it also covers a host that you add tomorrow.
One action performs one inventory read. The guard, the record, and the gate share that one answer, and the next action reads the inventory again.
A pattern that the resolver cannot expand is not an empty pattern. The action refuses in every context, an interactive turn included. The gate reports it like this when the target guard has already passed:
The host set did not resolve, so the blast radius is unknown (<cause>). Add an
inventory the resolver can read, or install ansible-core so the resolver can ask
ansible for its own configuration.
Execution Contexts
Each turn carries one execution context. The value states who drives the turn.
| Context | Set by | Policy treats it as |
|---|---|---|
interactive | a channel-driven turn | attended |
automation | a session cron job, a local trigger, or a long-horizon goal | unattended |
subagent | a subagent turn | unattended |
"Unattended" means that nobody waits on the turn. Only the exact value interactive earns attended trust. Every other value counts as unattended, and a turn that states no context counts as automation.
A subagent is always unattended, even when its parent session runs in a chat channel. Its prompt is model-authored and its transcript is durable.
The two built-in cron jobs are the exception. Read Which Turns Count as Unattended before you write a policy for them.
An unattended context asks nobody. A matching standing grant is the only allow path under grant, and it is the only allow path under approve. There is no interactive fallback and nothing auto-approves. A prompt with nobody present becomes a hang or a rubber stamp, and both outcomes are worse than a narrow grant.
The schema also accepts allow in the unattended block. That value permits every command at that scope, so it removes the grant list as the boundary.
The Decision Matrix
Each cell of the policy holds one of four values.
| Value | Effect |
|---|---|
allow | the action runs |
approve | a person must permit the action at run time |
grant | the action runs only when a standing grant matches it |
deny | the action does not run |
Three rules join the four values:
- A standing grant answers both
grantandapprove. A match underapproveskips the prompt, because the operator declared that permission in advance. - A standing grant never overrules
deny. The refusal names the key to change instead, so a grant that matches nothing visible does not confuse a reader. - An unmatched
grantrefuses, and the refusal names the class, the scope, and the hosts a grant must list.
mutate.inventory and credential.access take one value each rather than a scope table. A standing grant cannot satisfy mutate.inventory, because a grant that permitted an inventory write could point a granted name at another address.
credential.access covers one decryption, and never the action. Under approve and under grant, the authorization the action already carries answers this class. A human approval satisfies approve. A matched standing grant satisfies both values. An allow on the mutate.remote matrix satisfies both values as well, so no person answers twice for one action. Under deny the credential stays encrypted, and the action reaches no host.
The Shipped Default Policy
An absent gates block produces this policy. An unreadable block produces it too.
{
"gates": {
"approvers": [],
"approvalPaths": [
"webui"
],
"approvalTimeoutS": 120,
"interactive": {
"mutate.remote": {
"host": "approve",
"group": "approve",
"all": "deny"
},
"mutate.inventory": "allow",
"credential.access": "approve"
},
"unattended": {
"mutate.remote": {
"host": "deny",
"group": "deny",
"all": "deny"
},
"mutate.inventory": "deny",
"credential.access": "deny"
},
"standingGrants": [],
"audit": {
"retentionDays": 90,
"recordCommandText": false
}
}
}
What each default refuses:
| Default | What it refuses |
|---|---|
unattended.mutate.remote.host is deny | a cron job or a subagent cannot run a command on one host |
unattended.mutate.remote.group is deny | a cron job or a subagent cannot run a command on a host group |
unattended.mutate.remote.all is deny | no context reaches an unbounded host set |
unattended.mutate.inventory is deny | an automation cannot create, edit, or delete a server record |
unattended.credential.access is deny | no unattended turn may resolve a credential |
interactive.mutate.remote.host is approve | a chat request on one host, until a runtime approval permits it |
interactive.mutate.remote.group is approve | a chat request on a host group, until a runtime approval permits it |
interactive.mutate.inventory is allow | nothing, because an operator who edits inventory in chat does normal work |
interactive.credential.access is approve | nothing on its own, because the action already carries its own authorization |
standingGrants is empty | every automation, because no grant covers any command |
approvers is empty | every runtime approval, because nobody may answer one |
approvalPaths holds one entry | an approval on a request that arrived on that same path |
approvalTimeoutS is 120 | an answer that arrives more than 120 seconds after the request |
audit.recordCommandText is false | the resolved command text in a record, which keeps secrets out of the log |
The two approve defaults refuse under the shipped config, because gates.approvers is empty. Read The Approval Path before you rely on them.
The gateway states the policy in force at start, and a deployment with cron jobs and no grants gets a second line. Read both lines rather than the block you believe you wrote. See Confirm the Posture Is Live for the two lines and for what each clause of them means. See configuration-security.md#how-the-gate-reads-this-block for what a mistyped top-level gates key loads.
Choose a Posture
The sections above state each key. This section states the decision that you make with them. Does this agent act alone, or does it ask you first?
Three postures cover the deployments this design serves. Each one names the exact config, what it permits, what it refuses, and what it costs you.
The shipped default is not one of the three. It refuses every unattended remote action. It also names no approver, so a fresh install runs no remote command at all.
Write every key of a context block that you declare. A partial block is not a merge with the shipped values. See
configuration-security.md#policy-keysfor what an omitted key takes, and for the remote actions a partialinteractiveblock then refuses.
Posture 1: Approval for Every Remote Command
A person permits each remote command. The agent reaches no host without one human answer.
{
"gates": {
"approvers": [
{ "channel": "webui", "sender": "webui" }
],
"approvalPaths": ["webui"],
"approvalTimeoutS": 120,
"interactive": {
"mutate.remote": { "host": "approve", "group": "approve", "all": "deny" },
"mutate.inventory": "allow",
"credential.access": "approve"
},
"unattended": {
"mutate.remote": { "host": "deny", "group": "deny", "all": "deny" },
"mutate.inventory": "deny",
"credential.access": "deny"
},
"standingGrants": []
}
}
The sender value must equal the actor string exactly. A bare API token asserts webui. A trusted proxy asserts webui:<identity>. See configuration-security.md#who-may-answer-an-approval.
What it permits. The agent runs one remote command per human answer. The scope of that command is one host or one host group. An operator edits inventory from a chat session or from the WebUI.
What it refuses. A cron job, a sustained goal, and a subagent run no remote command. No unattended turn writes inventory. No unattended turn reads a credential. No context reaches an unbounded host set.
What it costs you. Read these five facts before you ship this posture.
One approver at least, or nothing runs. The executor asks whether a correct answer can exist, before it suspends the action. With an empty gates.approvers no answer can exist, so the action refuses at once. For the refusal text, see Path Independence.
That refusal also latches mutate.remote for the rest of that session today. The latch answers before the next request leaves the agent, and only an operator lifts it from the WebUI. A deployment gap therefore costs a click that teaches nothing. See A Denial Is Terminal.
The turn waits on the socket. gates.approvalTimeoutS sets the deadline. See configuration-security.md#policy-keys for its default and its range. The executor holds the action on the connection that carried it, so the chat turn stays open for the whole wait. Nobody answers, and the action then refuses with one expired record.
The answer arrives somewhere else. A chat turn in the WebUI arrives on the websocket path, and the inbox answers on the webui path. The two strings differ, so the check passes. One browser session and one API token still hold both halves, so this independence is nominal on a WebUI-only install. See Path Independence.
The approvals routes now require an API token holding the approve scope. They refuse a token issued without that scope. That does not change the WebUI case, because its bootstrap token holds every scope. It is what makes the independence enforceable rather than only nominal. You can issue a client a token that starts turns and cannot answer for them.
The WebUI inbox is the only screen that answers. No chat channel carries an approval today. A deployment without the WebUI channel suspends the action, and nobody answers it. The action expires after the deadline. See webui.md#approvals-inbox.
The credential needs no second answer. credential.access at approve reads the authorization that the action already carries. The human approval satisfies it. A matched standing grant satisfies it. An allow on the matrix satisfies it too. So one action costs one human decision.
Posture 2: Autonomous for Known Work, and Nothing Else
An automation runs the commands that you named in advance. It runs nothing else.
{
"gates": {
"unattended": {
"mutate.remote": { "host": "grant", "group": "deny", "all": "deny" },
"mutate.inventory": "deny",
"credential.access": "grant"
},
"standingGrants": [
{
"id": "nightly-nginx-reload",
"contexts": ["unattended"],
"hosts": ["staging-web-01"],
"commands": ["systemctl reload nginx"]
}
]
}
}
This block sets the unattended half. Add the interactive half from posture 1 or from posture 3. Write every key of that half, for the reason in the note above.
credential.access is grant here for a reason. A granted command against a server with a secretRef needs a credential decision as well. The shipped deny refuses that decryption, so the granted command reaches no host. A value of grant reads the grant that the action carries, so the decryption follows the grant.
A working example. A cron job reloads nginx on one staging host each night. The job runs the command systemctl reload nginx on the server record staging-web-01. The grant above covers that action. The audit log then holds two allow records for the run, one for mutate.remote and one for credential.access. Both records carry nightly-nginx-reload in grant_id, and both carry automation in execution_context.
What a grant is not. A grant is not a pattern. commands holds exact resolved command strings, and one character decides. The entry systemctl reload nginx covers no other command. A drifted command reads this refusal:
Refusing mutate.remote at host scope in a unattended context. A standing grant
must list every resolved host (staging-web-01) and the exact command. See
gates.standingGrants.
A grant covers a whole action, and it follows the record that it names. See the five match rules under Standing Grants for the host rule and for the re-resolution rule. An operator who repoints a record changes what they granted, on purpose. The audit record names the addresses that ran.
A grant is what suits an unattended context. The gate asks nobody there. A grant cell with no match refuses. An approve cell refuses as well, because no person waits on that turn. A deny cell refuses a matched grant too, and the refusal names the key to change. See Standing Grants.
What it costs you. An ad-hoc command from an automation never runs. Each new command costs a config edit. A command that changes its flags stops matching, and the refusal above tells you which grant to widen.
Posture 3: Autonomous Inside One Scope
One operator holds the install. The agent acts on one host without a question. No automation reaches a host.
{
"gates": {
"interactive": {
"mutate.remote": { "host": "allow", "group": "deny", "all": "deny" },
"mutate.inventory": "allow",
"credential.access": "approve"
},
"unattended": {
"mutate.remote": { "host": "deny", "group": "deny", "all": "deny" },
"mutate.inventory": "deny",
"credential.access": "deny"
},
"standingGrants": []
}
}
What it keeps. The agent acts on one host at a time. The resolver derives the scope from the resolved host set, so a group pattern refuses:
mutate.remote at group scope is deny for a interactive context.
An unattended turn still reaches nothing. all scope has no runtime path in any posture. The executor refuses that scope before it reads policy.
credential.access stays at approve, and the allow on the matrix satisfies it. An operator who permits the action at one scope authorizes the credential that the action needs. A refusal there would make allow mean nothing. The audit log still records each decryption.
What it gives up. No person reads the command before it runs on that host. The audit log is the only account of what ran, so read it as the control that this posture keeps. See The Audit Log.
Every sender who reaches the bot on an approved channel holds this privilege. The interactive context covers each channel-driven turn, and policy names no person. A channel allowFrom list is therefore the reachability boundary in this posture.
A one-off group action. Set interactive.mutate.remote.group to approve, and add the approver from posture 1. An approve cell with no approver refuses and latches instead.
Which Turns Count as Unattended
Only a live channel message earns the interactive context. Four kinds of work run unattended:
- a cron job that you create from a chat session or from the WebUI.
- a local trigger.
- a sustained goal, which keeps the agent at work after the operator leaves the chat.
- a subagent turn, plus the
systemchannel that announces a finished subagent.
A turn that carries no request context counts as unattended as well. The fail-closed value is automation.
A second startup line names the automations that no grant covers. See Confirm the Posture Is Live for that line.
The two built-in jobs are the exception today. The dream job and the heartbeat job run through a direct agent call, and that call sets no automation metadata. Their turns therefore carry the interactive context, and the interactive half of the matrix covers them. The second startup line still names both ids, because both are cron jobs.
The dream job runs with a restricted tool registry that holds four file tools, so it reaches no host and it needs no grant. The heartbeat job runs the task list in HEARTBEAT.md with the whole tool registry. So a heartbeat task that names a remote command reads your interactive policy:
| Posture | What a remote heartbeat task does |
|---|---|
| Posture 1 | it suspends, nobody answers, and it expires after the deadline |
| Posture 2 | it reads the interactive half that you added, and an unattended grant does not cover it |
| Posture 3 | it runs on one host with no person present |
Keep remote commands out of HEARTBEAT.md under posture 3, or accept that they run unattended at interactive privilege.
Confirm the Posture Is Live
Three surfaces state the policy in force. Read all three after a config change.
1. The startup echo. nanoinfra gateway logs one line at start. Posture 2 reads like this, and <abi> is the number that your kernel reports:
gates: unattended mutate.remote host=grant group=deny all=deny,
mutate.inventory=deny, credential.access=grant, 1 standing grant. confinement:
landlock abi <abi> on each helper process, filesystem rules, a tcp port allowlist
for the fetcher, and no tcp listener
The line covers the unattended half, the grant count, and the confinement of the helper processes. It covers no interactive value. Posture 1 and posture 3 deny the whole unattended half. The line for each of those postures ends the policy clause with (shipped defaults, no gates policy in config). Those words report one fact: the unattended block holds every shipped value. A policy that denies every unattended action on purpose reads the same words. For the confinement clause, read deployment.md#what-the-startup-echo-prints.
A second startup line names the automations that no grant covers. Expect that line under posture 1 and under posture 3. It reports policy, and it reports no fault:
gates: 3 automation(s) will be refused for remote actions: heartbeat, dream,
nightly-reload. Declare gates.standingGrants for the commands they run, or they
stay refused.
The line names cron job ids. It appears only when the deployment holds automations and holds no grant. One grant silences the line for every automation, so read the silence as no evidence about the rest.
2. The policy panel. Open Settings → Security → Capability gates. A value that still holds a shipped default carries a default marker, and a value that you set carries none. The remaining markers are the decisions that you did not take. See webui.md#gate-policy-panel.
3. The audit log. The gate writes one record per decision. Four fields name the posture that answered. decision and execution_context hold the outcome and the kind of turn. A posture 2 run also carries a grant_id. A posture 1 run carries an approval_id, an actor, and an approval_path. A posture 3 run carries neither a grant_id nor an approval_id. See webui.md#audit-log-viewer.
Standing Grants
A standing grant is one permission that you declare in advance. It permits mutate.remote and nothing else.
{
"gates": {
"unattended": {
"mutate.remote": { "host": "grant", "group": "grant", "all": "deny" }
},
"standingGrants": [
{
"id": "nginx-reload",
"contexts": ["unattended"],
"hosts": ["staging-web-01", "staging-web-02"],
"commands": ["systemctl reload nginx"]
}
]
}
}
Five match rules apply:
- The command must match one entry of
commandsexactly. The field is not a pattern language, and a command that differs by one character does not match. - Every resolved host of the action must appear in the grant. Partial coverage is no coverage.
- The gate compares resolved addresses, and not the names you typed. It resolves each grant host through the same resolver the action used.
- The gate re-resolves on each action. An inventory edit between two actions therefore invalidates a stale match.
- A grant past its
expiresAtmatches nothing. Seeconfiguration-security.md#standing-grant-keysfor that key, for what an omitted value means, and for the refusal an expired grant produces.
Set the matrix cell to grant for a grant to apply. A grant beside a deny cell changes nothing, and the refusal says so:
Standing grant nginx-reload covers this action, but
gates.unattended.mutate.remote.host is 'deny'. Set it to 'grant' for the grant to
apply.
The audit record names the grant that matched. A grant without an id appears as grant[0], grant[1], and so on, by its position in the list.
A Grant for a Connector Call
A data connector call has neither a host nor a command string, so the host rule and the command rule above cannot match it. A grant covers one by naming the connector and the operation:
{
"id": "calendar-hold",
"contexts": ["unattended"],
"connectors": ["google-calendar"],
"operations": ["create_event"]
}
Exact names, as with commands: a grant naming create_event covers create_event and nothing else.
A grant names one kind of action. It names hosts and commands, or it names connectors and operations. A config that writes both fails to load. Such a grant would have to mean either "and" or "or", and both readings are wrong. A config that writes connectors without operations also fails, because half a grant matches nothing. An operator would otherwise wait for half a grant to work. Neither kind leaks into the other. The gate matches a connector call against connectors and operations, and a command against hosts and commands.
A Grant Is Over an Action, Not Over a Caller
The id is a label. The gate reads it to name the grant in a record and in an allow reason. It plays no part in matching. Contexts, resolved hosts and exact commands decide, and nothing else does.
So a grant written for one automation permits that command on those hosts in any unattended turn — another automation, a trigger, a subagent turn. Renaming the automation, or the grant, changes nothing. This is the intended shape: a grant is a permission over an action, not a permission handed to a caller. Write the narrowest command and host list you can, because that list is the whole boundary.
A Grant From an Approval
You rarely write one of these by hand. Two surfaces produce them. Commissioning reports the grant a refused automation needs. The approvals inbox writes one from an action you just approved, under Approve and add. That grant expires in 24 hours, in 7 days, or never. See webui.md#approve-and-add.
A grant derived from an approval cannot be wider than the action it came from. The command is the exact string the executor rendered, and the binding digest covers that same text. The hosts are the addresses the executor resolved. contexts names the one context the action ran in. Nothing the browser sent reaches the grant. A grant built from a request field would widen authority by editing a request.
That exactness has a cost, and it is worth stating. You will press add more than once for what feels like one action. A different flag is a different command. That is the model working. The alternative is a pattern language, and commands refuses one on purpose. A pattern would turn one click into a much wider grant than the action it came from.
The write happens in the gateway process and never in the executor. Config is your authority, and it constrains the executor. So the executor holds a read-only rule on config.json. The write also never blocks the approval. A read-only config costs you the grant, and never the action.
Commissioning: Learn the Grant Before the Schedule Does
An automation is written as prose, so its commands are not in its text — the model composes them when it runs. Nothing can enumerate them by reading the record, which is why nanoinfra rehearses instead of guessing.
Creating an automation runs it once, immediately, with every gated tool forced to preview. The rehearsal resolves and evaluates. It acts on nothing, so it needs no permission of its own. What it reports is what a scheduled run would meet:
- each action it would take, and whether the schedule would permit it.
- the standing grant that would permit a refused one, in the form config takes.
- the credential cell, because a permitted command still fails if
gates.unattended.credential.accessrefuses the decryption it needs. - a shadowed cell, when a grant exists and the matrix refuses anyway.
- the latch, because a latched session refuses before anything consults the gate, so a correct grant changes nothing until an operator clears it.
nanoinfra saves an automation disabled, with the finding attached, when the rehearsal finds that it would be refused. The authoring work survives, and no automation sits enabled while certain to refuse at 03:00. Fix the finding, then rehearse again from Rehearse on the automation. That control also answers "what would this thing do now?" without waiting for the automation's slot.
Two answers sit on that card, and they are not the two on an approval card:
| Approval card | Commissioning card | |
|---|---|---|
| What it means | an action is suspended and waiting on you | nothing is running. This is what the automation will do on every run |
| What answering does | releases one action | writes a standing permission |
| Answers | approve / deny | Not now / Grant it |
There is no allow once on a commissioning card, because no action is suspended. Grant it writes the proposed grant to gates.standingGrants. It records who promoted it, and from which finding. It then asks for a restart, because the gateway reads the policy at startup. The route takes no grant from the request. It writes only what a rehearsal already found. A grant the caller could name would be a grant the caller chose.
What Commissioning Does Not Prove
A rehearsal observes one run. The model may compose a different command next month: a -p, a different unit name, or a path that varies by date. commands matches exact strings, so the gate refuses that run. That is the boundary working, not the rehearsal failing.
The rehearsal cannot cover two automations at all, and both say so rather than guessing:
- A trigger carries no stored message. Its content arrives from whoever fires it. A rehearsal of an invented message would propose a grant for a command the real firing may never use. Fire it once while you are watching instead.
- The rehearsal previews an inventory write (
create_server,update_server,delete_server), and it reports that write as ungrantable. A standing grant can never permit one (#23). Only the matrix cell changes that.
The Approval Path
A decision of approve suspends the action inside the executor. A human answers it, and then the executor runs the action. Two paths answer one: the approvals inbox in the WebUI, and a slash command in a chat channel.
The Suspend and the Wait
The executor holds the action on the connection that carried it. The tool call blocks for the length of the wait. It does not poll, because a second submission would resolve the host set again.
gates.approvalTimeoutS sets the length of the wait. See configuration-security.md#policy-keys for its default and its range. The ceiling equals the longest life of an approval token, so no wait outlasts the token that covers it.
The executor works in a fixed order, and the order decides what an operator reads:
- It refuses at once when no correct answer can exist. A suspension that nobody may answer is a hang.
- It renders the payload from resolver output.
- It writes one
approverecord to the audit log. - It waits.
Each connection gets its own thread. One suspended action therefore never stops another session.
The pending store lives in memory inside the executor. A restart drops every unanswered action, and the caller then reads a deployment fault. A durable record would let a restart revive an action that no human answered.
One action holds one inventory read for the whole wait. The command that runs after an approval is the command the operator read.
Three Ends of One Wait
| End | What the caller reads | The audit record |
|---|---|---|
| An operator approves | the action runs | one allow record with the actor and the approval path |
| An operator denies | a terminal refusal | one denied record |
| Nobody answers in time | a terminal refusal | one expired record |
An expiry reaches the audit log. The reason text is fixed:
no operator answered before the deadline, so the action expired. Ask again when an
approver is present, or declare a standing grant
A denial and an expiry both latch the capability class for that session. A restart restores a latch from a denied record and never from an expired one, because an expiry is a decision that nobody took.
The Second Socket
The executor binds two sockets. The agent holds the execute socket. An operator answers on a separate operator socket, and the executor owns both.
An answer cannot arrive on the execute socket. The agent holds that socket, so an answer accepted there would let a compromised agent approve its own action.
Three properties hold for the operator socket:
- It carries mode
0660and a group of its own. - It lives in a private subdirectory of the execute socket's directory. A sibling name in one shared directory would inherit the agent's group through the setgid bit.
- Its file name carries the execute socket's own name, so two executors in one run directory never unlink each other's socket.
The wire carries three verbs. pending lists the suspended actions with their payloads. approve answers one action. deny answers one action.
For the group in the container image, and for what the split gives up there, read deployment.md#the-operator-socket.
Answer From a Chat Channel
A deployment that runs only the WebUI has a second path in name alone. The request arrives on websocket, and the answer arrives from the same browser with the same token. Two slash commands make a real second path exist:
/approve <request-id>
/deny <request-id> <reason>
Telegram carries both today. The commands run before the model gets the turn. So the text never reaches a transcript, and no model can read an answer or write one.
Four properties decide whether an answer counts:
- The identity comes from the channel. The channel authenticates a sender, and the executor matches that sender against
gates.approvers. No argument of either command names a person, and no argument names a path. - A Telegram approver is the numeric account id. The channel appends a username to its own sender id, and a username changes at will. Config lists the account id, which is also the chat the request arrives in.
- An approver also needs reachability. A chat approver needs an entry in
allowFromas well, because that list decides whether the message reaches the bot. See Approvers Are Not Reachability. - A denial costs less than an approval. A denial sends one message. An approval sends one message and the gateway reads the digest from the executor before it answers.
The gateway delivers each suspended action to every approver that path independence admits, and it delivers the payload the executor rendered. It repeats a delivery that failed and never repeats one that arrived.
One residual risk is worth stating. These commands run inside the gateway process, so the file mode on the operator socket protects nothing on this path. Three rules carry the rest. The answer crosses a process boundary into the executor. The executor matches the actor against git-reviewed config. And no tool module can reach the answer surface. A tool that runs arbitrary code inside the gateway defeats the third rule, and the approver match is then the last one that holds.
To add a second channel, put the channel name in gates.approvalPaths, put one {channel, sender} entry in gates.approvers, and check that the channel forwards both commands.
Path Independence
The approval must arrive on an authenticated path that is not the path of the request. Three conditions hold together:
- The approver matches an entry of
gates.approverson the path that carried the answer. - That path appears in
gates.approvalPaths. - That path differs from the origin path of the request.
The origin path names the channel that raised the turn. The agent states it, and no tool argument sets it. A compromised agent can therefore claim any origin. The answer arrives on a socket the executor owns for that reason, and the approver set lives in git-reviewed config for the same reason.
When a Second Person Is the Second Factor
gates.identityIndependence lets a different person replace a different path. It ships off, and the reason is not caution.
With the flag on, an answer counts when either half differs. The answering identity differs from the origin identity, or the answering path differs from the origin path. Either one is enough. An answer never counts when both are the same, so self-approval is impossible in every mode. A request that names no origin identity keeps the path rule alone, because a missing value must never match everybody.
The identity half decides exactly one case: an answer that arrived on the origin path. On any other path the path half already permits the answer. So both identities always come from one path when the comparison matters, which is what makes comparing them meaningful.
What you trade. The origin identity is an assertion of the agent, exactly as the origin path is. The executor has no independent channel to the person who typed the message, so it cannot check either one. A compromised agent can therefore claim that a request came from another person, and one genuine human approval then substitutes for two authenticated paths.
What still holds is the part that was doing the work all along. The human reads the resolved command and the expanded host list that the executor rendered. The approval binds to a digest of those bytes. What you give up is narrower and real: the property that one compromised account cannot hold both halves.
A refusal names which rule failed, so an operator reads the case rather than the word "denied":
| Refusal | What happened |
|---|---|
same_path | the flag is off, and the answer arrived on the origin path |
same_actor_and_path | one person raised the request and answered it, on one path |
Turn it on for a deployment that runs one channel and more than one operator, which is the case the path rule cannot serve. Leave it off for a deployment that has a second channel, because a second channel is the stronger answer to the same question.
One consequence reaches the delivery of a suspended action. With the flag on, an approver on the origin path becomes a valid target, because they may be a different person. The person who raised the request is never a target. The gate, the feasibility check and the delivery watcher all read one function for this. So none of the three can drift from the other two.
Two chats on one channel are one path, because they share the credential and the transport. A requester who approves on the origin channel is single-factor, because one compromised account then yields both halves.
The shipped gates.approvers list is empty, so an interactive approve refuses at once. The refusal names the empty list, the configured paths, and the origin of the request:
gates.approvers lists nobody on a second authenticated path, so nobody may answer
this request. gates.approvalPaths lists ['webui'], and the request arrived on
'websocket'. Add an approver on another path, or declare a standing grant.
A path name and a channel name come from one vocabulary. webui is an answer path and never a channel, so a chat turn in the WebUI arrives on the websocket path. A deployment that lists only the origin channel in gates.approvalPaths reads the other refusal:
no second authenticated path is configured. Add one, or declare a standing grant.
gates.approvalPaths lists ['telegram'], and the request arrived on 'telegram'.
See troubleshooting.md#policy-refusals.
An approve value in the unattended block never waits for anybody:
mutate.remote at group scope is 'approve' for an unattended context, and no person
waits on this turn. A runtime approval there is a hang or a rubber stamp, so set
gates.unattended.mutate.remote.group to 'grant' and declare a standing grant.
The Digest Binding
The payload is the thing an approval covers. Every variable byte of it is the resolved command or a resolved host name.
- It shows the command exactly as the executor will run it.
- It shows every resolved host on its own line, plus a count. A group of fourteen hosts renders as fourteen names and never as the group name.
- It carries no model-authored text, so nobody approves a sentence while the executor runs a command.
- Its digest binds the command and the host set. The session id, the scope, and the pattern travel beside the payload, and the digest covers none of the three.
An answer carries the digest of the bytes the operator read. A digest that does not match refuses the answer, and the action stays pending:
This answer carries a digest of other bytes. An approval covers the payload the
executor rendered, so it authorizes nothing here.
A refused answer reaches the audit log under the decision approval_refused. It latches nothing, because one mistyped actor must not block a session that a real approver can still answer.
Single Use and Expiry
- One action takes one answer. A second answer reads
This action already has an answer. One action takes one answer. - The approval mints a token inside the executor. The executor spends that token when the action runs, and it spends it once.
- The token binds one session and one resolved action, so it cannot cover a second command in the same session.
- No token reaches the agent. The executor refuses a request that carries a nonce:
This request carries an approval nonce. The executor issues every nonce and gives
none to the agent, so no caller on this socket can hold one.
- An answer that arrives after the deadline does not land. The executor keeps the spent record for a while, so a late answer reads
expiredrather thanno such request.
Preview Is Not Permission
execute_on_server takes a dry_run argument, and it defaults to true. A preview resolves the server and reports what would run. It reaches no host and it resolves no credential, so it needs no permission.
A preview also reports what a real run would meet. It reports the decision for the current execution context. It reports the grant that matched, or the grant that is missing. It reports the cell that shadows one. Both evaluations are pure, so asking costs nothing and reaches nothing. The answer is a hypothetical, and it authorizes nothing. nanoinfra deliberately writes no denial to the audit log for a preview. A denial there latches the session, and asking a question must not block the automation the question is about.
dry_run=false asks to execute. It does not authorize execution. The gate decides, and no value on the call changes the answer. The two cases carry different wording, so nobody reads a withheld action as a preview:
Nothing was run, and nobody asked for a preview. This call asked to execute, and
the capability gate did not permit execution, so the action is shown instead. The
same call gets the same answer, and no argument on the call changes it. Only
operator policy does.
A Denial Is Terminal
A denial ends the action. It is not a retryable error, because a retryable denial invites a changed command until one command passes.
A denial also latches the capability class for that session. While the latch holds, the gate refuses the same class and asks nobody. A fresh prompt would be the oracle that this rule removes.
Four properties follow:
- The result text says that the action is over, and it carries no retry advice.
- The gate counts every refusal that arrives under the latch.
- Only an operator lifts the latch, from the WebUI. Elapsed time never lifts it, a new turn never lifts it, and the model never lifts it.
- A restart does not lift it. The gateway rebuilds latch state from the audit log at start, so a deny, restart, and retry loop gains nothing.
The model reads this text after a denial:
Denied by the capability gate: <reason>
This action is over. It will not run, and a changed command for the same purpose
will not run either. Further mutate.remote actions in this session get a refusal
with no prompt to anybody. Only an operator lifts that block, outside this
conversation. Report the outcome and continue with work that needs no
mutate.remote action.
A later attempt in the same session reads this text:
Refused by the capability gate: a mutate.remote action was already denied in this
session. Nobody was asked, because that denial stands.
Clear a latch from the WebUI. See webui.md#capability-gate-surfaces.
The Audit Log
The gate writes one record per decision. The record covers an allow, a denial, an expiry, and a latched refusal. An action that runs writes a second record when it ends, so the log answers what the gate decided and what happened next.
| Property | Value |
|---|---|
| Location | ~/.nanoinfra/gates/gate-YYYY-MM-DD.jsonl, one segment per UTC day |
| Write mode | one O_APPEND write plus one fsync per record |
| Owner | the executor writes it, and the agent account reads it |
| Retention | gates.audit.retentionDays deletes whole expired segments |
| Command text | a digest by default, and the text only under gates.audit.recordCommandText |
Each line holds these fields:
record_id, follows, ts, session_id, execution_context, origin_path, approval_path, same_path, actor, capability_class, scope, hosts, host_count, secret_ref, command_digest, decision, reason, grant_id, approval_id, token_nonce, exit_code, duration_ms, tool.
record_id names one record. follows names the decision record a completion belongs to, and it is null on a decision.
secret_ref and approval_id belong to a credential.access decision. The first names the credential that a decryption covered. The second names the suspended action that a human answered, so a reviewer can ask which approval authorized one decryption. Neither field carries a bearer value. A record also holds command_text while gates.audit.recordCommandText is on.
The decision field uses the operator's vocabulary:
| Value | What happened |
|---|---|
allow | the gate permitted the action, and the executor ran it |
approve | the executor suspended the action and waited for a human |
denied | the gate refused the action, or an operator denied it |
expired | nobody answered a suspended action before the deadline |
approval_refused | an answer arrived and did not count, and the action stayed pending |
completion | an action that the gate permitted has ended |
grant_promoted | an operator turned a commissioning finding into a standing grant |
grant_written | an approval in the inbox also wrote the standing grant for its action |
A restart rebuilds the denial latches from the denied records alone. That is
also why nanoinfra never writes a previewed decision as denied. It would latch
the session that asked the question.
A grant_promoted record names the actor, the path they answered on, and the
grant id. It also names the hosts, the command, and the automation whose finding
it came from. It is the answer to "who gave this permission, and why?"
A grant that appeared in config with no record of who promoted it would be worse than a hand-written one. A hand-written grant at least has a git history.
A grant_written record answers the same question for a grant that came out of
the approvals inbox. It carries grant_id and, in approval_id, the suspended
action the operator answered. So a reader pairs it with the decision record for
that same approval. It is a second row rather than a field on that decision,
because this log is append-only. The executor writes the decision before the
action runs, and the gateway writes the grant after the answer lands.
The Completion Record
A decision record lands before the action runs, and that order is the reason the log exists. So an outcome arrives as a second record and never as an edit of the first. An append-only log with an edit is not one.
A completion copies the session, the class, the context, the scope, the hosts, and the command digest. It adds exit_code and duration_ms. It carries no grant, no approval, and no actor, because the decision record it names holds those answers. Pair the two by record_id and follows.
Four endings write a completion:
| Ending | exit_code |
|---|---|
| the command finished | the code the host returned |
| the command finished with a failure | the non-zero code the host returned |
| the wait for the host timed out | none, and the reason says the code is unknown |
| the transport was lost | none, and the reason says the code is unknown |
A missing exit code and a code of zero are opposite facts, so the record never guesses one. A refusal writes no completion, because nothing ran. A preview writes none for the same reason.
A completion holds no command output, under any setting. gates.audit.recordCommandText covers the command a decision names, and no setting adds output. Output carries the same risk as a resolved command, and the log is not a second secret store.
Four design rules matter to an operator:
- The log is separate from the session transcripts, and it has its own retention. It never enters the session history.
- The record holds a command digest by default. A resolved command often embeds a secret, so full text would make the log a second secret store.
- A failed write refuses the action. An action that nothing records does not run.
- The store never rewrites a record that it keeps. Retention deletes a whole day segment instead.
Read the log in the WebUI, or with a shell on the host. See webui.md#capability-gate-surfaces.
Approvers Are Not Reachability
gates.approvers is the only source of approval authority. Membership in a channel allowFrom list grants nothing, and membership in the pairing store grants nothing.
Those two lists decide who can reach the bot. The pairing store also changes at run time from chat, and an injected instruction attacks a runtime-mutable list first. The approver set, the standing grants, and the scope denials therefore live in config, where a git review covers them.
Related Pages
configuration-security.md#capability-gatesfor every key and every default.deployment.md#the-process-splitfor the processes, the accounts, and the residual risks.deployment.md#per-process-confinementfor the Landlock policy on each helper.architecture.md#process-splitfor the five processes and the sockets.secrets-and-servers.mdfor the credential store and the server inventory.data-connectors.mdfor a data source whose operations each declare a class.webui.md#approvals-inboxfor the screen that answers a suspended action.webui.md#capability-gate-surfacesfor the policy panel, the latch banner, and the log viewer.troubleshooting.md#capability-gate-problemsfor the failure modes and their symptoms.