> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nanoclaw.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Hardening

> Lock down a NanoClaw install — egress lockdown, the mount allowlist, sender policies, the command gate, and container resource limits.

NanoClaw's isolation boundary is the Docker container: each agent runs in its own container with a scoped workspace mount (see [Container lifecycle](/concepts/container-lifecycle)). There is no micro-VM or alternative sandbox runtime — hardening means tightening what those containers can reach. This page walks the defense-in-depth controls from network to filesystem to people.

## Lock down network egress

By default, agent containers have normal outbound internet access. Set `NANOCLAW_EGRESS_LOCKDOWN=true` to force all agent traffic through the OneCLI gateway instead:

* Agent containers are placed on an `--internal` Docker network (default name `nanoclaw-egress`, override with `NANOCLAW_EGRESS_NETWORK`). Internal networks have no route to the internet.
* The OneCLI gateway container (default name `onecli`, override with `ONECLI_GATEWAY_CONTAINER`) is attached to that network with the alias `host.docker.internal`, so the injected proxy is the only reachable hop.
* Agents run non-root without `NET_ADMIN`, so they can't reconfigure the network from inside.

The setup is idempotent and self-healing — NanoClaw creates the network and re-attaches the gateway on every spawn if needed. It also **fails fast**: if lockdown is enabled but the network can't be created or the gateway container can't be attached, NanoClaw throws an `EgressLockdownError` and refuses to spawn the agent rather than silently running it with open egress. If you see that error, start the gateway container (or set `NANOCLAW_EGRESS_LOCKDOWN=false` to opt out).

All three variables can be set in `.env` or the process environment (`src/config.ts:92-95`) — `.env` is the simpler path for a plain "edit and restart" flow; the service definition (launchd plist or systemd unit) still works too, as described in [Configuration](/operate/configuration#applying-changes):

```bash theme={null}
# Linux (systemd unit)
Environment=NANOCLAW_EGRESS_LOCKDOWN=true
```

## Gate additional mounts

Per-group container config can request `additional_mounts` — extra host directories mounted into the container. Every request is validated against an allowlist at `~/.config/nanoclaw/mount-allowlist.json`. The file lives **outside the project root** on purpose: container agents can edit files in their workspace, so the security config has to sit where they can't reach it.

**If the allowlist file doesn't exist, all additional mounts are blocked.** That's the default posture; you opt in by creating the file.

Mounts are also operator-only to *edit*: `ncl groups config add-mount` and `remove-mount` run only from the host socket — never from inside a container, even for an agent with global CLI scope — and take effect on the next `ncl groups restart`. Whatever they add still passes the allowlist check at spawn.

```json mount-allowlist.json theme={null}
{
  "allowedRoots": [
    { "path": "~/projects", "allowReadWrite": true, "description": "Development projects" },
    { "path": "~/Documents/work", "allowReadWrite": false, "description": "Work documents (read-only)" }
  ],
  "blockedPatterns": ["password", "secret", "token"]
}
```

Validation rules, in order:

1. **Container path** must be relative, non-empty, and contain no `..` or `:` (prevents path traversal and Docker `-v` option injection). Validated mounts land under `/workspace/extra/`.
2. **Host path** is tilde-expanded and resolved through symlinks to its real path; it must exist.
3. **Blocked patterns** — the real path must not match any pattern. Your `blockedPatterns` are merged with a built-in default set (`.ssh`, `.gnupg`, `.aws`, `.kube`, `.docker`, `credentials`, `.env`, `.netrc`, `id_rsa`, `id_ed25519`, and similar) that you can't remove.
4. **Allowed roots** — the real path must sit under one of `allowedRoots`.
5. **Read-only by default** — a mount is read-write only when it explicitly requests it *and* the matching root has `allowReadWrite: true`. Otherwise it's forced read-only.

Rejected mounts are logged with the reason and skipped; the container still starts with its valid mounts. Use the `/manage-mounts` skill to view, add, or remove entries conversationally, or write the config through the setup step:

```bash theme={null}
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
```

<Note>
  The validator accepts either key: `allowReadWrite` (native) or `readOnly` (written by the `/manage-mounts` skill and setup). If both are absent it defaults to read-only. `readOnly: true` means read-only, `readOnly: false` means read-write — the validator translates it to `allowReadWrite = !readOnly` and logs a warning when it does (`src/modules/mount-security/index.ts`, `normalizeRoot`). Likewise, a top-level `nonMainReadOnly` field may appear in configs written by setup, but the mount validator doesn't read it.
</Note>

The allowlist is cached in memory, so restart the service after changes.

## Restrict who can talk to your agents

Each messaging group carries an `unknown_sender_policy` that decides what happens when someone who isn't an owner, admin, or member of the wired agent group writes in:

| Policy             | Behavior                                                                                                                                                                                                                                       |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `strict`           | Message dropped silently and recorded in the dropped-messages log (`ncl dropped-messages list`).                                                                                                                                               |
| `request_approval` | Message dropped, and an Allow / Deny card is DM'd to an owner or admin. On approve, the sender becomes a group member and the original message is re-routed. Duplicate requests from the same sender are deduplicated while a card is pending. |
| `public`           | Anyone can talk to the agent — the access check is skipped entirely.                                                                                                                                                                           |

Channels auto-created by the router (someone @mentions or DMs the bot in an unwired chat) get `request_approval`. To harden a chat to silent-drop:

```bash theme={null}
ncl messaging-groups update --id <mg-id> --unknown-sender-policy strict
```

Sender approvals are handled entirely through the DM card — there's no `ncl` resource for them (`ncl approvals` covers a different approval queue). Use `ncl dropped-messages list` to audit who got dropped and why. Note that the approval flow needs an owner or admin with a reachable DM channel — on a fresh install with no owner configured, approval requests are skipped and logged. Wirings can additionally set `sender_scope known`, which requires explicit membership even on a `public` messaging group — see [ncl CLI](/operate/ncl-cli).

## The command gate

Slash commands are classified on the host before they ever reach a container:

* **Filtered commands** (`/start`, `/help`, `/login`, `/logout`, `/doctor`, `/config`, `/remote-control`) are dropped silently — they manipulate host-side CLI state and never reach the agent.
* **Admin commands** (`/clear`, `/compact`, `/context`, `/cost`, `/files`, `/upload-trace`) require the sender to hold an `owner` or `admin` role in `user_roles`; everyone else gets a "Permission denied" reply.
* Everything else passes through unchanged.

If the permissions module isn't installed (no `user_roles` table), admin commands are allowed for everyone — install the module to get the role check.

## Cap container resources

These limits bound what a runaway agent can consume. All three can be set in `.env` or the process environment (see [Configuration](/operate/configuration#read-from-env)):

| Variable                 | Default | Limits                                                                                                                                                                                                                                                                                                                                                                               |
| ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `CONTAINER_CPU_LIMIT`    | *unset* | CPU cap per container, passed to `docker run --cpus` (e.g. `2`). Opt-in; unset leaves containers unbounded.                                                                                                                                                                                                                                                                          |
| `CONTAINER_MEMORY_LIMIT` | *unset* | Memory cap per container, passed to `docker run --memory` (e.g. `8g`). A hard cap only on a swapless host — there a runaway is OOM-killed; with swap it can spill past the limit.                                                                                                                                                                                                    |
| `CONTAINER_PIDS_LIMIT`   | `2048`  | Process/thread cap per container, passed to `docker run --pids-limit`. Unlike the other two this one is **on by default**, as a fork-bomb backstop. cgroups v2 counts threads rather than processes and a browsing agent with several tabs open runs into the high hundreds, so the default sits well clear of normal use. Set it to `0` (or any non-numeric value) to drop the cap. |

<Note>
  There is no env var for a container-count or output-size cap, and no concurrency-cap env var — `CONTAINER_TIMEOUT`, `IDLE_TIMEOUT`, `CONTAINER_MAX_OUTPUT_SIZE`, and `MAX_CONCURRENT_CONTAINERS` were removed from the code. Idle/stuck containers are now caught by the periodic host-sweep loop (`src/host-sweep.ts`) — see [Container killed mid-task](/operate/troubleshooting#container-killed-mid-task).
</Note>

## Always-on container hardening

Some of the container's posture isn't configurable at all. Every spawn gets the same flags, unconditionally — there is no per-group and no per-install override (`hardeningArgs`, `src/container-runner.ts:261`):

* **`--cap-drop=ALL`** and **`--security-opt no-new-privileges`** — depth against a root-in-container path. Under the normal `--user` mapping they're inert (the capability sets are already empty and the image carries no file capabilities), which is the point: they cost nothing and cover the case where the mapping doesn't apply.
* **`--init`** — Docker's own init runs as PID 1 and forwards signals to the agent runner. Not optional: the spawn's `--entrypoint bash` override defeats the image's tini, and Linux discards default-action signals sent to PID 1, so without it SIGTERM would be ignored and every `docker stop` would end in SIGKILL after the full grace period.
* **`--shm-size=1g`** — Docker defaults `/dev/shm` to 64 MB, which silently short-writes past that size. `agent-browser` passes `--disable-dev-shm-usage`, but a third-party Puppeteer or Playwright launcher inside the container may not.

The agent image adds one more that isn't a flag: the build strips the setuid-root bit from every binary Debian ships (`mount`, `su`, `passwd`, and friends), none of which an unprivileged agent needs (`container/Dockerfile:125`).

## Credentials

Keep API keys and OAuth tokens out of agent containers entirely — the OneCLI Agent Vault injects them at the proxy layer so agents never see raw secrets. See [Credentials](/operate/credentials).
