Skip to main content

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.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:

  1. The capability class of the tool.
  2. The scope of the action, which is the number of hosts it reaches.
  3. 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.

ClassMembersThe gate
readread_file, list_dir, find_files, grep, web_fetch, web_search, list_servers, get_server, get_diagram, a data connector's read operationsno decision
mutate.localwrite_file, edit_file, apply_patch, exec, cron, create_diagram, update_diagramno decision
mutate.inventorycreate_server, update_server, delete_serverone decision per write
mutate.remoteexecute_on_server, a data connector's write operationsone decision per action
credential.accessthe resolution of a secretRef to a plaintext credentialsee 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, and 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.

ScopeMeaning
hostexactly one host
groupa pattern that resolves to more than one host
allan unbounded pattern, the literal all, or a wildcard

all accepts one value in the schema, which is deny. A config that asks for another value fails to load. 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:

ProviderHow the resolver names the hosts
sshthe host field, so the scope is always host
ssmthe instanceId field, so the scope is always host
apithe hostname of baseUrl, so the scope is always host
ansible-runnerit 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.

ContextSet byPolicy treats it as
interactivea channel-driven turnattended
automationa session cron job, a local trigger, or a long-horizon goalunattended
subagenta subagent turnunattended

"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.

ValueEffect
allowthe action runs
approvea person must permit the action at run time
grantthe action runs only when a standing grant matches it
denythe action does not run

Three rules join the four values:

  • A standing grant answers both grant and approve. A match under approve skips 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 grant refuses, 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.

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:

DefaultWhat it refuses
unattended.mutate.remote.host is denya cron job or a subagent cannot run a command on one host
unattended.mutate.remote.group is denya cron job or a subagent cannot run a command on a host group
unattended.mutate.remote.all is denyno context reaches an unbounded host set
unattended.mutate.inventory is denyan automation cannot create, edit, or delete a server record
unattended.credential.access is denyno unattended turn may resolve a credential
interactive.mutate.remote.host is approvea chat request on one host, until a runtime approval permits it
interactive.mutate.remote.group is approvea chat request on a host group, until a runtime approval permits it
interactive.mutate.inventory is allownothing, because an operator who edits inventory in chat does normal work
interactive.credential.access is approvenothing on its own, because the authorization of the action answers it
standingGrants is emptyevery automation, because no grant covers any command
approvers is emptyevery runtime approval, because nobody may answer one
approvalPaths holds one entryan approval on a request that arrived on that same path
approvalTimeoutS is 120an answer that arrives more than 120 seconds after the request
audit.recordCommandText is falsethe 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. A mistyped top-level gates key loads as silent defaults, so read this line rather than the block you believe you wrote:

gates: unattended mutate.remote host=deny group=deny all=deny,
mutate.inventory=deny, credential.access=deny, no standing grants, so no
automation may run a remote command (shipped defaults, no gates policy in config).
confinement: landlock abi <abi> on each helper process, filesystem rules, a tcp
port allowlist for the fetcher, and no tcp listener

The line states the unattended half of the matrix. It states no interactive value. The words (shipped defaults, no gates policy in config) 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 deployment with cron jobs and no grants gets a second line:

gates: 3 automation(s) will be refused for remote actions: <ids>. Declare
gates.standingGrants for the commands they run, or they stay refused.

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 key that you omit inside interactive or unattended takes the field default, and that default is deny. The shipped interactive values apply only when the whole interactive block is absent. A partial interactive block therefore sets credential.access to deny, and every remote action against a server with a secretRef 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.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:

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.

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. It defaults to 120 seconds, and the schema accepts 1 to 300. 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, and a token issued without it is refused there. That does not change the WebUI case — its bootstrap token holds every scope — but it is what makes the independence enforceable rather than only nominal: a client can be given 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. An absent interactive key keeps the shipped values, and a partial one does not.

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 or none of it. Every resolved host of the action must appear in the grant. Partial coverage is no coverage.

A grant follows the record that it names. The gate resolves each grant host through the resolver that the action used, and it compares the resolved addresses. It resolves again on each action, and it keeps no resolution between two actions. So an inventory write between two turns cannot redirect a grant to another address. An operator who repoints a record changes what they granted, on purpose, and 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 schema accepts one value there, and 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 system channel 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. 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.

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 line above 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:

PostureWhat a remote heartbeat task does
Posture 1it suspends, nobody answers, and it expires after the deadline
Posture 2it reads the interactive half that you added, and an unattended grant does not cover it
Posture 3it 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, so both lines end the policy clause with (shipped defaults, no gates policy in config).

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 commands exactly. 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 expiresAt matches nothing. A grant with no expiresAt never expires, which is what every grant written before that key existed means.

An expired grant is read as absent and reported as expired, with the date. The state this wording exists for is a turn that ran unattended yesterday and waits for a human today: "no standing grant names this action" would send you to write the grant that is already in your config. Nothing prunes the expired line, and that is deliberate — an application that deleted rows from your config file would make that file something other than the authority.

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 four rules 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. hosts and commands, or connectors and operations — a config that writes both fails to load, because such a grant would have to mean either "and" or "or" and both readings are wrong. Neither kind leaks into the other: a connector call is matched 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, and 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, and the approvals inbox writes one from an action you just approved — Approve and add, expiring in 24 hours, 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 — the same text the binding digest covers — the hosts are the addresses it resolved, and contexts names the one context the action ran in. Nothing the browser sent reaches the grant, because a grant built from a request field would be a way to 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, since a different flag is a different command. That is the model working. The alternative is a pattern language, which commands refuses on purpose, and which 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 the executor is the thing being constrained by it, so the executor holds a read-only rule on config.json. And the write is never allowed to block the approval: a read-only config costs you the grant, 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.access refuses the decryption it needs;
  • a shadowed cell, when a grant exists and the matrix refuses anyway;
  • the latch, because a latched session refuses before the gate is consulted, so a correct grant changes nothing until an operator clears it.

An automation the rehearsal finds would be refused is saved disabled with the finding attached. The authoring work survives, and no automation sits enabled while certain to refuse at 03:00. Fix the finding and rehearse again from Rehearse on the automation, which is also how you answer "what would this thing do now?" without waiting for its slot.

Two answers sit on that card, and they are not the two on an approval card:

Approval cardCommissioning card
What it meansan action is suspended and waiting on younothing is running; this is what the automation will do on every run
What answering doesreleases one actionwrites a standing permission
Answersapprove / denyNot 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, records who promoted it and from which finding, and asks for a restart — the gateway reads the policy at startup. The route takes no grant from the request: it writes only what a rehearsal already found, because 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, a path that varies by date — and commands matches exact strings, so that run is refused. That is the boundary working, not the rehearsal failing.

Two automations cannot be rehearsed at all, and both say so rather than guessing:

  • A trigger carries no stored message. Its content arrives from whoever fires it, so rehearsing an invented message would propose a grant for a command the real firing may never use. Fire it once while you are watching instead.
  • An inventory write (create_server, update_server, delete_server) is previewed and reported as ungrantable, because 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. It defaults to 120 seconds, and the schema accepts 1 to 300. 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:

  1. It refuses at once when no correct answer can exist. A suspension that nobody may answer is a hang.
  2. It renders the payload from resolver output.
  3. It writes one approve record to the audit log.
  4. 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

EndWhat the caller readsThe audit record
An operator approvesthe action runsone allow record with the actor and the approval path
An operator deniesa terminal refusalone denied record
Nobody answers in timea terminal refusalone 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 0660 and 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.
  • allowFrom and the pairing store grant nothing. Both carry reachability. An approver needs an entry in both, for two different reasons: gates.approvers decides whether an answer counts, and allowFrom decides whether the message reaches the bot at all.
  • 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:

  1. The approver matches an entry of gates.approvers on the path that carried the answer.
  2. That path appears in gates.approvalPaths.
  3. 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 the answering identity differs from the origin identity, or when 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, and 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":

RefusalWhat happened
same_paththe flag is off, and the answer arrived on the origin path
same_actor_and_pathone 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 expired rather than no 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: the decision for the current execution context, the grant that matched or the grant that is missing, and the cell that shadows one. Both evaluations are pure, so asking costs nothing and reaches nothing. The answer is a hypothetical and authorizes nothing — and it is deliberately not written to the audit log as a denial, because 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:

  1. The result text says that the action is over, and it carries no retry advice.
  2. The gate counts every refusal that arrives under the latch.
  3. 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.
  4. 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.

PropertyValue
Location~/.nanoinfra/gates/gate-YYYY-MM-DD.jsonl, one segment per UTC day
Write modeone O_APPEND write plus one fsync per record
Ownerthe executor writes it, and the agent account reads it
Retentiongates.audit.retentionDays deletes whole expired segments
Command texta 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:

ValueWhat happened
allowthe gate permitted the action, and the executor ran it
approvethe executor suspended the action and waited for a human
deniedthe gate refused the action, or an operator denied it
expirednobody answered a suspended action before the deadline
approval_refusedan answer arrived and did not count, and the action stayed pending
completionan action that the gate permitted has ended
grant_promotedan operator turned a commissioning finding into a standing grant
grant_writtenan 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 a previewed decision is never written as denied: it would latch the session that asked the question.

A grant_promoted record names the actor, the path they answered on, the grant id, the hosts and 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, which 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:

Endingexit_code
the command finishedthe code the host returned
the command finished with a failurethe non-zero code the host returned
the wait for the host timed outnone, and the reason says the code is unknown
the transport was lostnone, 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.