Skip to main content

Security Configuration

Every field that decides what needs approval, who may give it, and who is allowed to talk to the agent at all. For the model behind these fields, read Capability Gates and Identity and SSO.

This page is one of four. The configuration reference was a single 2,929-line document. Its own second section was a hand-written table of contents — a page admitting it could not be navigated. The reference is now split by subject, so the fields for a thing sit beside the pages that explain the thing:

PageHolds
Configurationhow config is loaded, secrets, environment variables, channels, and the deployment-wide settings
Provider and Model Configurationevery provider, model preset, fallback and transcription field
Agent and Tool Configurationnamed agents, tool groups, web tools, MCP, knowledge and connectors
Security Configurationcapability gates, approvers, standing grants and pairing

Examples are snippets to merge into ~/.nanoinfra/config.json, not replacement files. The docs use camelCase because nanoinfra writes config that way.

Security​

[!TIP] For production deployments, set both "restrictToWorkspace": true and "tools.exec.sandbox": "bwrap" in your config. restrictToWorkspace enables nanoinfra's application-level workspace guards. tools.exec.sandbox isolates each shell command at the process level. New installations get restrictToWorkspace: true by default. Set the key explicitly even so. When an upgraded config omits the key, the loader silently pins it to false, which preserves the existing behaviour.

For API keys, tokens, and other secrets, see Environment Variables for Secrets — avoid storing them directly in config.json.

[!NOTE] A restricted WebUI chat can select a project outside the configured agent workspace. That project then becomes the normal file and shell boundary. nanoinfra adds capability-specific, read-only access for built-in skills, the agent workspace's skills/ directory, and the exact agent memory/history.jsonl file. Neighboring memory/profile files and all cross-workspace writes remain denied. Agent-owned SOUL.md and USER.md are assembled by nanoinfra into model context directly. This does not grant file tools broader access to the agent workspace.

OptionDefaultDescription
tools.workspacesRoot~/.nanoinfra/workspacesWhere workspaces live, and the boundary the WebUI may select one inside. The Workspaces explorer lists this directory's children plus the configured agents.defaults.workspace, and refuses any other path — a workspace outside the root is reachable only because config names it, so point this at the directory holding your projects rather than typing a path in the browser. Declared rather than derived: the parent of the default workspace is ~/.nanoinfra, which holds config.json and the secrets store.
tools.restrictToWorkspacetrueWhen true, enables nanoinfra's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace. nanoinfra adds selected internal roots as read-only or explicitly write-enabled roots, and it keeps media uploads read-only by default. Shell execution rejects workspace-external working_dir values and applies best-effort command path checks, but this is not an OS sandbox. This default changed from false to true. An existing config.json that does not set the key keeps false, so upgrading never starts blocking commands that used to run. A startup warning says so. Set the key explicitly to choose.
tools.exec.sandbox""Sandbox backend for shell commands. Set to "bwrap" to wrap exec calls in a bubblewrap sandbox — the process can only see the workspace (read-write) and media directory (read-only). Config files and API keys are hidden. Automatically enables workspace restriction for file tools. Linux only — requires bwrap installed (apt install bubblewrap. Pre-installed in the Docker image). Not available on macOS (bwrap depends on Linux kernel namespaces).
tools.exec.enabletrueWhen false, the shell exec tool is not registered at all. Use this to completely disable shell command execution.
tools.exec.timeout60Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap. Set 0 to disable the hard timeout for trusted long-running commands.
tools.exec.pathPrepend""Extra directories to prepend to PATH when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's bin or Scripts directory.
tools.exec.pathAppend""Extra directories to append to PATH when running shell commands (e.g. /usr/sbin for ufw).
tools.exec.sandboxRoBinds[]Extra absolute paths to read-only bind into the "bwrap" sandbox with --ro-bind-try, such as /home/user/.local/bin or /home/user/.cargo/bin when those paths are also in pathPrepend/pathAppend. These roots are also accepted by the shell absolute-path guard only while bwrap is active. Bind only directories whose contents are safe for agent commands to read. Paths equal to or containing the active workspace are ignored so they cannot uncover its masked parent directory.
tools.exec.sandboxRwBinds[]Extra absolute paths to read-write bind into the "bwrap" sandbox with --bind-try, for trusted tool caches or scratch directories. Use sparingly: paths listed here are intentionally writable by shell commands inside the sandbox. Paths equal to or containing the active workspace are ignored.
tools.exec.allowPatterns[]Regexes that exempt a command from denyPatterns and from the built-in deny list. Matched with re.fullmatch against each top-level shell segment of the lowercased command, and a chained command is exempt only when every segment matches. Setting this turns exec into an allowlist: a command that matches nothing here is refused with "not in allowlist", so an empty-by-default deployment is unaffected and a populated one is closed by default.
tools.exec.denyPatterns[]Regexes that refuse a command, added to the built-in deny list. Matched with re.search against the lowercased command, so an unanchored pattern matches anywhere in it. Skipped for a command allowPatterns exempted.
tools.exec.allowedEnvKeys[]Environment variable names passed through to shell commands. Everything else is withheld, which is what keeps provider credentials out of a subprocess the model chose the arguments for.
tools.webuiAllowLocalServiceAccessfalseWhen true, shell commands and file previews may reach loopback addresses. Off by default because the gateway itself listens on loopback, so a command that can reach 127.0.0.1 can reach nanoinfra's own API. Accepted as allowLocalPreviewAccess for compatibility.
tools.webuiAllowRemotePackageInstallfalseWhen false, the WebUI can install missing optional packages only from a browser opened on the same machine as nanoinfra. Set to true only when a trusted remote admin is allowed to install Python packages into this nanoinfra environment.
tools.ssrfWhitelist[]CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as 192.168.1.50/32. Broad ranges increase SSRF exposure.
channels.*.allowFromomittedAccess control per channel. Omit to use pairing-only mode. Set ["*"] to allow everyone. Or list specific user IDs. See Pairing for details.

Docker security: The official Docker image runs as a non-root user (nanoinfra, UID 1000) with bubblewrap pre-installed. The default docker-compose.yml drops all Linux capabilities and keeps Docker's default AppArmor/seccomp profiles enabled. If you enable "tools.exec.sandbox": "bwrap" inside Docker, start Compose with docker-compose.bwrap.yml as an additional override so bubblewrap can create nested namespaces.

Capability Gates​

The gates block holds the policy for remote execution, inventory writes, and credential resolution. For the model behind these keys, read capability-gates.md first.

An absent gates block applies restrictive defaults. Those defaults refuse every unattended remote action, so an automation that runs a remote command needs a standing grant. For the whole default block, see The shipped default policy.

To decide whether this agent acts alone or asks you first, read Choose a Posture. It holds three complete gates blocks and the cost of each one.

The block below is one example. It is not the set of defaults.

{
"gates": {
"approvers": [
{ "channel": "webui", "sender": "webui" },
{ "channel": "telegram", "sender": "sender-id" }
],
"approvalPaths": ["webui", "telegram"],
"approvalTimeoutS": 120,
"interactive": {
"mutate.remote": { "host": "approve", "group": "approve", "all": "deny" },
"mutate.inventory": "allow",
"credential.access": "approve"
},
"unattended": {
"mutate.remote": { "host": "grant", "group": "grant", "all": "deny" },
"mutate.inventory": "deny",
"credential.access": "deny"
},
"standingGrants": [
{
"id": "nginx-reload",
"contexts": ["unattended"],
"hosts": ["staging-web-01", "staging-web-02"],
"commands": ["systemctl reload nginx"]
},
{
"id": "approval-2026-09-03-systemctl-9f3a1c",
"contexts": ["interactive"],
"hosts": ["10.0.0.11"],
"commands": ["systemctl restart nginx"],
"expiresAt": "2026-09-10T14:22:05+00:00",
"note": "Added by approve and add on 2026-09-03: webui:ops@example.com approved 8c1f… and chose 7 days, so this grant expires 2026-09-10T14:22:05+00:00."
}
],
"audit": { "retentionDays": 90, "recordCommandText": false }
}
}

Policy Keys​

OptionDefaultDescription
gates.approvers[]The only list that grants approval authority. An empty list means that nobody can approve an action at run time.
gates.approvers[].channel(required)The path that carries the approval, such as webui. It uses the same names as gates.approvalPaths.
gates.approvers[].sender(required)The actor string that the path authenticates. The comparison is exact, so a case difference does not match.
gates.approvalPaths["webui"]The paths that authenticate an approver. An approval must arrive on a path that is not the path of the request.
gates.approvalTimeoutS120How long the executor holds one suspended action. Accepts 1 to 300. The ceiling equals the longest life of an approval token.
gates.identityIndependencefalseWhen true, a different person on the origin path can answer, and a different path is no longer required. Self-approval stays impossible either way. The origin identity is an assertion of the agent, so read what you trade before you turn it on.
gates.interactivesee belowPolicy for a channel-driven turn, where a person waits.
gates.unattendedsee belowPolicy for a cron job, a long-horizon goal, and a subagent turn.
gates.standingGrants[]Permissions that you declare in advance. An empty list means that no automation can run a remote command.
gates.audit.retentionDays90How many days of gate decisions the log keeps. The gate deletes whole expired day segments. Accepts 1 or more, and a lower value fails to load.
gates.audit.recordCommandTextfalseWhen true, each record also holds the resolved command text. A resolved command often embeds a secret.

Both context blocks take the same three keys:

Optioninteractive defaultunattended defaultAccepts
mutate.remote.hostapprovedenyallow, approve, grant, deny
mutate.remote.groupapprovedenyallow, approve, grant, deny
mutate.remote.alldenydenydeny only
mutate.inventoryallowdenyallow, deny
credential.accessapprovedenyallow, approve, grant, deny

Write every key of a context block that you declare. A key that you omit inside interactive or unattended takes the field default, which is deny. The defaults in the table above apply only when the whole context block is absent. A partial interactive block therefore sets credential.access to deny. Every remote action against a server with a secretRef then refuses.

mutate.remote.all accepts one value. A config that sets another value fails to load, because this design has no runtime path to an unbounded host set.

For what each of the four decision values means, and for how credential.access reads them, see capability-gates.md#the-decision-matrix.

Who May Answer an Approval​

An approve decision suspends the action, and a human answers it in the WebUI. Two keys decide whether that answer counts. Read capability-gates.md#the-approval-path for the whole rule.

gates.approvers[].sender must equal the actor string exactly. The WebUI builds that string from the server-side session, and a browser cannot name itself:

DeploymentThe actor stringThe entry to write
A bare API tokenwebui{ "channel": "webui", "sender": "webui" }
A trusted proxy that asserts an identitywebui:<identity>{ "channel": "webui", "sender": "webui:ops@example.com" }

A deployment behind Cloudflare Access therefore lists webui:ops@example.com rather than ops@example.com. See webui.md#approvals-inbox.

The shipped gates.approvers list is empty, so every approve decision refuses until you add one entry. The refusal names the empty list.

gates.approvalPaths needs no second entry for a WebUI chat. That turn arrives on the websocket path, and the inbox answers on the webui path, so the two paths differ. A second entry matters when a request arrives on the only path in the list. That request then has no independent answer path, and the refusal names the missing one.

Standing Grant Keys​

OptionDefaultDescription
idomittedA name for the grant. The audit record uses it. Without an id, the record names the grant by its list position, such as grant[0].
contexts["unattended"]Where the grant applies. Accepts interactive, unattended, or both.
hosts[]Inventory record names. The gate resolves each name to an address and compares addresses, not names.
commands[]Exact resolved command strings. This field is not a pattern language.
connectors[]Data connector names, for a grant that covers a connector call instead of a command.
operations[]Exact operation names of those connectors. Not a pattern language either.
expiresAtomittedAn absolute timestamp. After it, the gate treats the grant as absent. Omitted means the grant never expires, so every config written before this key keeps its meaning. A value with no offset reads as UTC.
noteomittedFree text for whoever reads this file next. Nothing matches on it. Approve and add writes the duration it was given and the approval it came from here, because config.json is JSON and JSON has no comments.

expiresAt is absolute rather than a duration on purpose. Somebody who reads this file in six months needs a date, and not a subtraction from a start nobody recorded.

Nothing prunes an expired grant. The gate stops matching it and the line stays in your file, marked expired in the WebUI. An application that deleted lines from the operator's config would make that file something other than the authority. A stale line you can read beats a silent edit.

The gate therefore reads an expired grant as absent, and it reports it as expired, with the date. That wording exists for one state: a turn ran unattended yesterday, and it waits for a human today. "No standing grant names this action" would send you to write the grant that is already in your config. The refusal names the grant and the date instead:

Refusing mutate.remote at host scope in an unattended context. A standing grant must list
every resolved host (10.0.0.11) and the exact command. See gates.standingGrants. Standing
grant approval-2026-09-03-systemctl-9f3a1c covers this action, and it expired at
2026-09-10T14:22:05+00:00. Nothing removed it: set a later expiresAt on that grant, or
approve the action once and add a fresh grant.

Approve and Add​

The approvals inbox also writes a grant for you, from an action you just approved. The second entry of standingGrants above is one of those. For what a derived grant can and cannot cover, see capability-gates.md#a-grant-from-an-approval. For the control itself, see webui.md#approve-and-add.

A grant permits mutate.remote and nothing else. It can never permit an inventory write. Set the matching mutate.remote scope to grant for a grant to apply.

For the rule that a grant names one kind of action, see capability-gates.md#a-grant-for-a-connector-call. For a connector grant in use, see data-connectors.md#a-write-unattended. For what the gate matches a grant on, see capability-gates.md#a-grant-is-over-an-action-not-over-a-caller. For the two surfaces that write a grant for you, see capability-gates.md#commissioning-learn-the-grant-before-the-schedule-does.

How the Gate Reads This Block​

  • Every object inside gates refuses an unknown key. A mistyped key such as allowCommnads fails to load, and the message names the key.
  • The root config still accepts extra keys. A mistyped top-level gates key therefore loads as the shipped defaults. Read the gates: line that the gateway logs at start to confirm the policy in force.
  • An unreadable gates block falls back to the same defaults. A malformed config never widens policy.
  • The gate re-reads the policy, the grants, and the approvers for each decision, so a tighter policy applies without a restart.
  • The gate reads an expired grant as absent, and reports it as expired. A grant that dies at 03:00 therefore needs no restart to stop working. The turn that waits for a human afterwards says which grant ran out.
  • The audit block is read once when a process starts. Restart the gateway after you change retentionDays or recordCommandText.
  • A grant written from the inbox also reaches the audit log, with the approval it came from. See capability-gates.md#the-audit-log for that record.

The WebUI edits this block under Settings → Security. See webui.md#capability-gate-surfaces.

Pairing​

Pairing lets users get access to the bot through a simple code exchange — no config editing required. This works for both new users and existing users connecting from a new channel (e.g. someone already approved on Telegram now setting up Discord).

How it works​

  1. A user sends a DM to the bot on a pairing-capable channel where they aren't yet approved. This includes Telegram, Discord, WhatsApp, and channels such as Slack or Mattermost when their DM policy is set to allowlist.
  2. The bot replies with a pairing code (like ABCD-EFGH) and tells them to forward it to you.
  3. You approve the code:
/pairing approve ABCD-EFGH
  1. The user can now chat with the bot normally.

Pairing only works in DMs — unapproved users in group chats are silently ignored.

Pairing-only mode​

By default, if you don't set allowFrom, pairing-capable channels can issue a pairing code when an unapproved user DMs the bot. This means you can skip allowFrom entirely and manage access through pairing:

{
"channels": {
"telegram": {
"enabled": true
}
}
}

Slack and Mattermost DMs are open by default. To use pairing there, set the channel's dm.policy to "allowlist" and leave dm.allowFrom empty until you approve users:

{
"channels": {
"slack": {
"enabled": true,
"dm": { "policy": "allowlist" }
}
}
}

If you prefer to allow everyone without approval:

{
"channels": {
"telegram": {
"enabled": true,
"allowFrom": ["*"]
}
}
}

Managing access​

CommandWhat it does
/pairingShow all pending pairing requests
/pairing approve <code>Approve a request — the sender can now chat
/pairing deny <code>Reject a pending request
/pairing revoke <user_id>Remove a previously approved user from the current channel
/pairing revoke <channel> <user_id>Remove a user from a specific channel

You can find user IDs in the output of /pairing list.

From the terminal:

nanoinfra agent -m "/pairing list"
nanoinfra agent -m "/pairing approve ABCD-EFGH"