mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f896caefa0 | |||
| 55c003c5f4 | |||
| 20f2bae5cd | |||
| d6b82e6473 | |||
| fea5ac5f2c | |||
| 2b86bbef19 | |||
| 9dc0a7e62f | |||
| 2035033397 | |||
| 1c294ff9a5 | |||
| 43198310e1 | |||
| b7d6eebf4d | |||
| 803f3413ec | |||
| a8b7da7bcf | |||
| 0b6ad5550d | |||
| c1965cfcaf | |||
| 3906104960 | |||
| ed9a3e330d |
@@ -13,18 +13,18 @@ Configure which host directories NanoClaw agent containers can access. The mount
|
|||||||
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
|
cat ~/.config/nanoclaw/mount-allowlist.json 2>/dev/null || echo "No mount allowlist configured"
|
||||||
```
|
```
|
||||||
|
|
||||||
Show the current config to the user in a readable format: which directories are allowed, whether non-main agents are read-only.
|
Show the current config to the user in a readable format: which directories are allowed, and whether each is read-only or read-write.
|
||||||
|
|
||||||
## Add Directories
|
## Add Directories
|
||||||
|
|
||||||
Ask which directories the user wants agents to access. For each path:
|
Ask which directories the user wants agents to access. For each path:
|
||||||
- Validate the path exists
|
- Validate the path exists
|
||||||
- Ask if it should be read-only for non-main agents (default: yes)
|
- Ask if it should be read-write (`allowReadWrite: true`) or read-only (`allowReadWrite: false`, the safer default)
|
||||||
|
|
||||||
Build the JSON config and write it:
|
Build the JSON config and write it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","readOnly":false}],"blockedPatterns":[],"nonMainReadOnly":true}'
|
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[{"path":"/path/to/dir","allowReadWrite":true}],"blockedPatterns":[]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--force` to overwrite the existing config.
|
Use `--force` to overwrite the existing config.
|
||||||
@@ -34,7 +34,7 @@ Use `--force` to overwrite the existing config.
|
|||||||
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
|
Read the current config, show it, ask which entry to remove, then write the updated config through the same write path (build the trimmed JSON and pass it to `--step mounts --force -- --json`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[],"nonMainReadOnly":true}'
|
pnpm exec tsx setup/index.ts --step mounts --force -- --json '{"allowedRoots":[],"blockedPatterns":[]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reset to Empty
|
## Reset to Empty
|
||||||
@@ -45,12 +45,10 @@ pnpm exec tsx setup/index.ts --step mounts --force -- --empty
|
|||||||
|
|
||||||
## After Changes
|
## After Changes
|
||||||
|
|
||||||
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
|
The allowlist is read fresh when a container is spawned, so new mounts apply to newly spawned containers automatically — no service restart needed.
|
||||||
|
|
||||||
Run from your NanoClaw project root:
|
To apply the new config to a group that already has a running container, restart just that group:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source setup/lib/install-slug.sh
|
ncl groups restart --id <group-id>
|
||||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
|
||||||
systemctl --user restart $(systemd_unit) # Linux
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ See [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md) for what's different an
|
|||||||
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
||||||
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||||
- **Web access** — search and fetch content from the web
|
- **Web access** — search and fetch content from the web
|
||||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
|
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional Docker Sandboxes micro-VM isolation
|
||||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||||
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
- **Agent templates**: stamp a ready-to-run agent (instructions + MCP tools + skills, no secrets) from a reusable bundle, via the setup wizard or `ncl groups create --template <ref>`. Load from the [public library](https://github.com/nanocoai/nanoclaw-templates), a local folder, or any git repo. See [docs/templates.md](docs/templates.md).
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ Key files:
|
|||||||
|
|
||||||
**Why Docker?**
|
**Why Docker?**
|
||||||
|
|
||||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, Apple Container is also supported as a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
|
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. For additional isolation, Docker Sandboxes run each container inside a micro VM.
|
||||||
|
|
||||||
**Can I run this on Linux or Windows?**
|
**Can I run this on Linux or Windows?**
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
# Apple Container Networking Setup (macOS 26)
|
|
||||||
|
|
||||||
Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs).
|
|
||||||
|
|
||||||
## Quick Setup
|
|
||||||
|
|
||||||
Run these two commands (requires `sudo`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Enable IP forwarding so the host routes container traffic
|
|
||||||
sudo sysctl -w net.inet.ip.forwarding=1
|
|
||||||
|
|
||||||
# 2. Enable NAT so container traffic gets masqueraded through your internet interface
|
|
||||||
echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef -
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface`
|
|
||||||
|
|
||||||
## Making It Persistent
|
|
||||||
|
|
||||||
These settings reset on reboot. To make them permanent:
|
|
||||||
|
|
||||||
**IP Forwarding** — add to `/etc/sysctl.conf`:
|
|
||||||
```
|
|
||||||
net.inet.ip.forwarding=1
|
|
||||||
```
|
|
||||||
|
|
||||||
**NAT Rules** — add to `/etc/pf.conf` (before any existing rules):
|
|
||||||
```
|
|
||||||
nat on en0 from 192.168.64.0/24 to any -> (en0)
|
|
||||||
```
|
|
||||||
|
|
||||||
Then reload: `sudo pfctl -f /etc/pf.conf`
|
|
||||||
|
|
||||||
## IPv6 DNS Issue
|
|
||||||
|
|
||||||
By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail.
|
|
||||||
|
|
||||||
The container image and runner are configured to prefer IPv4 via:
|
|
||||||
```
|
|
||||||
NODE_OPTIONS=--dns-result-order=ipv4first
|
|
||||||
```
|
|
||||||
|
|
||||||
This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check IP forwarding is enabled
|
|
||||||
sysctl net.inet.ip.forwarding
|
|
||||||
# Expected: net.inet.ip.forwarding: 1
|
|
||||||
|
|
||||||
# Test container internet access
|
|
||||||
container run --rm --entrypoint curl nanoclaw-agent:latest \
|
|
||||||
-s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com
|
|
||||||
# Expected: 404
|
|
||||||
|
|
||||||
# Check bridge interface (only exists when a container is running)
|
|
||||||
ifconfig bridge100
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Symptom | Cause | Fix |
|
|
||||||
|---------|-------|-----|
|
|
||||||
| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` |
|
|
||||||
| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` |
|
|
||||||
| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules |
|
|
||||||
| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image |
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
```
|
|
||||||
Container VM (192.168.64.x)
|
|
||||||
│
|
|
||||||
├── eth0 → gateway 192.168.64.1
|
|
||||||
│
|
|
||||||
bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs
|
|
||||||
│
|
|
||||||
├── IP forwarding (sysctl) routes packets from bridge100 → en0
|
|
||||||
│
|
|
||||||
├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP
|
|
||||||
│
|
|
||||||
en0 (your WiFi/Ethernet) → Internet
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26
|
|
||||||
- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building
|
|
||||||
@@ -9,5 +9,3 @@ The files in this directory are original design documents and developer referenc
|
|||||||
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
|
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
|
||||||
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
||||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
|
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
|
||||||
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
|
|
||||||
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
|
|
||||||
|
|||||||
+102
-60
@@ -2,69 +2,101 @@
|
|||||||
|
|
||||||
## Trust Model
|
## Trust Model
|
||||||
|
|
||||||
|
Privilege is **user-level**, persisted in the `user_roles` table (owner /
|
||||||
|
admin, global or scoped to an agent group) plus `agent_group_members` (the
|
||||||
|
unprivileged access gate).
|
||||||
|
|
||||||
| Entity | Trust Level | Rationale |
|
| Entity | Trust Level | Rationale |
|
||||||
|--------|-------------|-----------|
|
|--------|-------------|-----------|
|
||||||
| Main group | Trusted | Private self-chat, admin control |
|
| Owners / admins (`user_roles`) | Trusted | Hold owner/admin roles; gate admin commands and approve credentialed actions |
|
||||||
| Non-main groups | Untrusted | Other users may be malicious |
|
| Group members (`agent_group_members`) | Access-gated | Membership grants access to an agent group, but their messages are still untrusted input |
|
||||||
| Container agents | Sandboxed | Isolated execution environment |
|
| Unregistered senders | Untrusted | Subject to each messaging group's `unknown_sender_policy` |
|
||||||
| Incoming messages | User input | Potential prompt injection |
|
| Agent containers | Sandboxed | Long-lived per-session container; isolated by mounts, non-root, no host reach |
|
||||||
|
| Incoming messages | User input | Potential prompt injection regardless of who sent them |
|
||||||
|
|
||||||
## Security Boundaries
|
## Security Boundaries
|
||||||
|
|
||||||
### 1. Container Isolation (Primary Boundary)
|
### 1. Container Isolation (Primary Boundary)
|
||||||
|
|
||||||
Agents execute in containers (lightweight Linux VMs), providing:
|
Agents execute in containers (Docker), providing:
|
||||||
- **Process isolation** - Container processes cannot affect the host
|
- **Process isolation** — container processes cannot affect the host
|
||||||
- **Filesystem isolation** - Only explicitly mounted directories are visible
|
- **Filesystem isolation** — only explicitly mounted directories are visible
|
||||||
- **Non-root execution** - Runs as unprivileged `node` user (uid 1000)
|
- **Non-root execution** — runs as an unprivileged user (`node`, uid 1000, or the host uid remapped in)
|
||||||
- **Ephemeral containers** - Fresh environment per invocation (`--rm`)
|
- **Per-session containers** — one long-lived container per session polls that session's DBs and handles many messages, then is torn down (`--rm`) when the session goes idle.
|
||||||
|
|
||||||
This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted.
|
This is the primary security boundary. Rather than relying on application-level
|
||||||
|
permission checks, the attack surface is limited by what's mounted.
|
||||||
|
|
||||||
### 2. Mount Security
|
### 2. Mount Security
|
||||||
|
|
||||||
**External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is:
|
`buildMounts` (`src/container-runner.ts`) composes a fixed set of mounts per
|
||||||
- Outside project root
|
spawn. For the default (Claude) provider these are:
|
||||||
- Never mounted into containers
|
|
||||||
- Cannot be modified by agents
|
|
||||||
|
|
||||||
**Default Blocked Patterns:**
|
| Container path | Host source | Mode | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/workspace` | `data/v2-sessions/<group>/<session>/` | RW | Session folder — `inbound.db`, `outbound.db`, `outbox/`, `.claude/` |
|
||||||
|
| `/workspace/agent` | `groups/<folder>/` | RW | Agent group working files + `CLAUDE.local.md` |
|
||||||
|
| `/workspace/agent/container.json` | group `container.json` | RO | Container config — readable, not writable |
|
||||||
|
| `/workspace/agent/CLAUDE.md` | composed `CLAUDE.md` | RO | Regenerated every spawn; agent edits would be clobbered |
|
||||||
|
| `/workspace/agent/.claude-fragments` | group `.claude-fragments/` | RO | Composer skill/MCP fragments |
|
||||||
|
| `/workspace/global` | `groups/global/` | RO | Shared global memory |
|
||||||
|
| `/app/CLAUDE.md` | `container/CLAUDE.md` | RO | Shared base doc imported by the composed entry point |
|
||||||
|
| `/home/node/.claude` | `data/v2-sessions/<group>/.claude-shared/` | RW | Claude state, settings, skill symlinks |
|
||||||
|
| `/app/src` | `container/agent-runner/src/` | RO | Shared agent-runner source (same for all groups) |
|
||||||
|
| `/app/skills` | `container/skills/` | RO | Shared container skills |
|
||||||
|
| `/workspace/extra/<name>` | allowlisted host dir | RO (RW only if allowed) | Operator-configured additional mounts |
|
||||||
|
|
||||||
|
The config mounts (`container.json`, `CLAUDE.md`, `.claude-fragments`) are
|
||||||
|
**nested read-only mounts on top of the read-write group dir** — the agent can
|
||||||
|
read its config but cannot modify it. The project root is **never mounted**: the
|
||||||
|
container only ever sees the paths above plus any provider-contributed mounts
|
||||||
|
(e.g. an OpenCode XDG dir). Host application source (`src/`, `dist/`,
|
||||||
|
`package.json`) is not reachable.
|
||||||
|
|
||||||
|
**Additional-mount allowlist** — extra mounts from a group's container config
|
||||||
|
are validated against an allowlist at `~/.config/nanoclaw/mount-allowlist.json`,
|
||||||
|
which is:
|
||||||
|
- Outside the project root
|
||||||
|
- Never mounted into containers
|
||||||
|
- Not modifiable by agents
|
||||||
|
|
||||||
|
Its schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"allowedRoots": [
|
||||||
|
{ "path": "~/projects", "allowReadWrite": true, "description": "Dev projects" },
|
||||||
|
{ "path": "~/Documents/work", "allowReadWrite": false, "description": "Read-only" }
|
||||||
|
],
|
||||||
|
"blockedPatterns": ["password", "secret", "token"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
.ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker,
|
|
||||||
credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519,
|
**Default blocked patterns** (merged with any in the file):
|
||||||
|
```
|
||||||
|
.ssh, .gnupg, .gpg, .aws, .azure, .gcloud, .kube, .docker,
|
||||||
|
credentials, .env, .netrc, .npmrc, .pypirc, id_rsa, id_ed25519,
|
||||||
private_key, .secret
|
private_key, .secret
|
||||||
```
|
```
|
||||||
|
|
||||||
**Protections:**
|
**Enforcement** (`src/modules/mount-security/index.ts`):
|
||||||
- Symlink resolution before validation (prevents traversal attacks)
|
- **No allowlist file ⇒ every additional mount is blocked** — the fixed mounts above are unaffected, but nothing extra is granted until the operator creates the file.
|
||||||
- Container path validation (rejects `..` and absolute paths)
|
- Symlinks are resolved to their real path (`realpathSync`) before any check, defeating traversal via symlink.
|
||||||
- `nonMainReadOnly` option forces read-only for non-main groups
|
- The real path is rejected if it matches a blocked pattern, and rejected unless it sits under one of `allowedRoots`.
|
||||||
|
- The container path is validated: relative, non-empty, no `..`, no leading `/`, no `:` (blocks Docker `-v` option injection). It is mounted under `/workspace/extra/`.
|
||||||
**Read-Only Project Root:**
|
- **Read-write is granted only when the mount requests it (`readonly: false`) *and* the matched root has `allowReadWrite: true`.** Otherwise the mount is forced read-only.
|
||||||
|
|
||||||
The main group's project root is mounted read-only. Writable paths the agent needs (store, group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. The `store/` directory is mounted read-write so the main agent can access the SQLite database directly.
|
|
||||||
|
|
||||||
### 3. Session Isolation
|
### 3. Session Isolation
|
||||||
|
|
||||||
Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`:
|
Per-session state lives under `data/v2-sessions/<agent-group>/<session>/`
|
||||||
- Groups cannot see other groups' conversation history
|
(`inbound.db`, `outbound.db`, `outbox/`, `.claude/`). Claude state
|
||||||
- Session data includes full message history and file contents read
|
(`.claude-shared`) and the working folder are scoped to the agent group, so:
|
||||||
- Prevents cross-group information disclosure
|
- Different agent groups cannot see each other's conversation history or files.
|
||||||
|
- A group's sessions share that group's memory but keep separate message DBs.
|
||||||
|
|
||||||
### 4. IPC Authorization
|
This prevents cross-group information disclosure.
|
||||||
|
|
||||||
Messages and task operations are verified against group identity:
|
### 4. Credential Isolation (OneCLI Agent Vault)
|
||||||
|
|
||||||
| Operation | Main Group | Non-Main Group |
|
|
||||||
|-----------|------------|----------------|
|
|
||||||
| Send message to own chat | ✓ | ✓ |
|
|
||||||
| Send message to other chats | ✓ | ✗ |
|
|
||||||
| Schedule task for self | ✓ | ✓ |
|
|
||||||
| Schedule task for others | ✓ | ✗ |
|
|
||||||
| View all tasks | ✓ | Own only |
|
|
||||||
| Manage other groups | ✓ | ✗ |
|
|
||||||
|
|
||||||
### 5. Credential Isolation (OneCLI Agent Vault)
|
|
||||||
|
|
||||||
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
|
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
|
||||||
|
|
||||||
@@ -77,13 +109,12 @@ Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent V
|
|||||||
**Per-agent policies:**
|
**Per-agent policies:**
|
||||||
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
|
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
|
||||||
|
|
||||||
**NOT Mounted:**
|
**Never on the container filesystem:**
|
||||||
- Channel auth sessions (`store/auth/`) — host only
|
- The project root and `.env` — never mounted; the container only receives the paths in the mount table above.
|
||||||
- Mount allowlist — external, never mounted
|
- The mount allowlist — external (`~/.config/nanoclaw/…`), never mounted.
|
||||||
- Any credentials matching blocked patterns
|
- Real credentials — injected per request by the OneCLI gateway, never written into any mount.
|
||||||
- `.env` is shadowed with `/dev/null` in the project root mount
|
|
||||||
|
|
||||||
### 6. Egress Lockdown (Forced Proxy)
|
### 5. Egress Lockdown (Forced Proxy)
|
||||||
|
|
||||||
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
|
The `HTTPS_PROXY` env var only redirects *proxy-aware* clients — a tool that
|
||||||
ignores it (or a raw socket) could reach the internet directly and bypass
|
ignores it (or a raw socket) could reach the internet directly and bypass
|
||||||
@@ -111,31 +142,42 @@ no `host-gateway` route).
|
|||||||
exception: a heal failure there is logged but not fatal, since already-running
|
exception: a heal failure there is logged but not fatal, since already-running
|
||||||
agents stay on the internal net (no leak) until the gateway returns.
|
agents stay on the internal net (no leak) until the gateway returns.
|
||||||
|
|
||||||
|
**Default: egress is open.** Lockdown is **off** unless you opt in; by default
|
||||||
|
the agent reaches the OneCLI gateway over the host-gateway path and outbound
|
||||||
|
traffic is not confined to the internal network.
|
||||||
|
|
||||||
**Configuration:**
|
**Configuration:**
|
||||||
|
|
||||||
| Env | Default | Meaning |
|
| Env | Default | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). Enabled automatically by `/add-golden-registry`. |
|
| `NANOCLAW_EGRESS_LOCKDOWN` | `false` | Set `true` to opt in (otherwise the host-gateway path is used). |
|
||||||
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
|
| `NANOCLAW_EGRESS_NETWORK` | `nanoclaw-egress` | Network name. |
|
||||||
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
|
| `ONECLI_GATEWAY_CONTAINER` | `onecli` | Gateway container to attach. |
|
||||||
|
|
||||||
|
These variables are read from the **host process** environment (the service's
|
||||||
|
environment / `.env`), not from inside the container. The agent container is
|
||||||
|
started with only `TZ` and any provider-declared variables — host environment
|
||||||
|
variables, including secrets, are never forwarded into the agent.
|
||||||
|
|
||||||
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
|
**⚠ Behavior when enabled:** with lockdown on, agents have **no direct
|
||||||
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
|
internet** — all traffic must go through OneCLI. Proxy-aware clients (npm, pnpm,
|
||||||
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
|
pip, curl, node/bun with the proxy env) are unaffected. Any workflow that relies
|
||||||
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
|
on a **non-proxy-aware** tool reaching the internet directly will fail by design.
|
||||||
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
||||||
|
|
||||||
## Privilege Comparison
|
## Resource Limits
|
||||||
|
|
||||||
| Capability | Main Group | Non-Main Group |
|
Per-container CPU and memory caps are **opt-in and unset by default** — a runaway
|
||||||
|------------|------------|----------------|
|
agent is not throttled unless the operator configures a limit:
|
||||||
| Project root access | `/workspace/project` (ro) | None |
|
|
||||||
| Store (SQLite DB) | `/workspace/project/store` (rw) | None |
|
| Env | Default | Meaning |
|
||||||
| Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) |
|
| --- | --- | --- |
|
||||||
| Global memory | Implicit via project | `/workspace/global` (ro) |
|
| `CONTAINER_CPU_LIMIT` | *(empty — unbounded)* | Passed to `--cpus` when set (e.g. `2`). |
|
||||||
| Additional mounts | Configurable | Read-only unless allowed |
|
| `CONTAINER_MEMORY_LIMIT` | *(empty — unbounded)* | Passed to `--memory` when set (e.g. `8g`). |
|
||||||
| Network access | Unrestricted | Unrestricted |
|
|
||||||
| MCP tools | All | All |
|
Only `--memory` is a container-level cap; whether it's a *hard* cap depends on
|
||||||
|
the host having no swap (a deployment concern). On a swapless host a runaway is
|
||||||
|
OOM-killed at the limit.
|
||||||
|
|
||||||
## Security Architecture Diagram
|
## Security Architecture Diagram
|
||||||
|
|
||||||
@@ -149,7 +191,7 @@ Lockdown is **off by default**; opt in with `NANOCLAW_EGRESS_LOCKDOWN=true`.
|
|||||||
┌──────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ HOST PROCESS (TRUSTED) │
|
│ HOST PROCESS (TRUSTED) │
|
||||||
│ • Message routing │
|
│ • Message routing │
|
||||||
│ • IPC authorization │
|
│ • Role / access checks (user_roles, agent_group_members) │
|
||||||
│ • Mount validation (external allowlist) │
|
│ • Mount validation (external allowlist) │
|
||||||
│ • Container lifecycle │
|
│ • Container lifecycle │
|
||||||
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
|
│ • OneCLI Agent Vault (injects credentials, enforces policies) │
|
||||||
|
|||||||
@@ -1,359 +0,0 @@
|
|||||||
# Running NanoClaw in Docker Sandboxes (Manual Setup)
|
|
||||||
|
|
||||||
This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Host (macOS / Windows WSL)
|
|
||||||
└── Docker Sandbox (micro VM with isolated kernel)
|
|
||||||
├── NanoClaw process (Node.js)
|
|
||||||
│ ├── Channel adapters (WhatsApp, Telegram, etc.)
|
|
||||||
│ └── Container spawner → nested Docker daemon
|
|
||||||
└── Docker-in-Docker
|
|
||||||
└── nanoclaw-agent containers
|
|
||||||
└── Claude Agent SDK
|
|
||||||
```
|
|
||||||
|
|
||||||
Each agent runs in its own container, inside a micro VM that is fully isolated from your host. Two layers of isolation: per-agent containers + the VM boundary.
|
|
||||||
|
|
||||||
The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles network access and injects your Anthropic API key automatically.
|
|
||||||
|
|
||||||
> **Note:** This guide is based on a validated setup running on macOS (Apple Silicon) with WhatsApp. Other channels (Telegram, Slack, etc.) and environments (Windows WSL) may require additional proxy patches for their specific HTTP/WebSocket clients. The core patches (container runner, credential proxy, Dockerfile) apply universally — channel-specific proxy configuration varies.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- **Docker Desktop v4.40+** with Sandbox support
|
|
||||||
- **Anthropic API key** (the sandbox proxy manages injection)
|
|
||||||
- For **Telegram**: a bot token from [@BotFather](https://t.me/BotFather) and your chat ID
|
|
||||||
- For **WhatsApp**: a phone with WhatsApp installed
|
|
||||||
|
|
||||||
Verify sandbox support:
|
|
||||||
```bash
|
|
||||||
docker sandbox version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 1: Create the Sandbox
|
|
||||||
|
|
||||||
On your host machine:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a workspace directory
|
|
||||||
mkdir -p ~/nanoclaw-workspace
|
|
||||||
|
|
||||||
# Create a shell sandbox with the workspace mounted
|
|
||||||
docker sandbox create shell ~/nanoclaw-workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
If you're using WhatsApp, configure proxy bypass so WhatsApp's Noise protocol isn't MITM-inspected:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker sandbox network proxy shell-nanoclaw-workspace \
|
|
||||||
--bypass-host web.whatsapp.com \
|
|
||||||
--bypass-host "*.whatsapp.com" \
|
|
||||||
--bypass-host "*.whatsapp.net"
|
|
||||||
```
|
|
||||||
|
|
||||||
Telegram does not need proxy bypass.
|
|
||||||
|
|
||||||
Enter the sandbox:
|
|
||||||
```bash
|
|
||||||
docker sandbox run shell-nanoclaw-workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 2: Install Prerequisites
|
|
||||||
|
|
||||||
Inside the sandbox:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get update && sudo apt-get install -y build-essential python3
|
|
||||||
npm config set strict-ssl false
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 3: Clone and Install NanoClaw
|
|
||||||
|
|
||||||
NanoClaw must live inside the workspace directory — Docker-in-Docker can only bind-mount from the shared workspace path.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone to home first (virtiofs can corrupt git pack files during clone)
|
|
||||||
cd ~
|
|
||||||
git clone https://github.com/nanocoai/nanoclaw.git
|
|
||||||
|
|
||||||
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
|
|
||||||
WORKSPACE=/Users/you/nanoclaw-workspace
|
|
||||||
|
|
||||||
# Move into workspace so DinD mounts work
|
|
||||||
mv nanoclaw "$WORKSPACE/nanoclaw"
|
|
||||||
cd "$WORKSPACE/nanoclaw"
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
pnpm install
|
|
||||||
pnpm install https-proxy-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 4: Apply Proxy and Sandbox Patches
|
|
||||||
|
|
||||||
NanoClaw needs several patches to work inside a Docker Sandbox. These handle proxy routing, CA certificates, and Docker-in-Docker mount restrictions.
|
|
||||||
|
|
||||||
### 4a. Dockerfile — proxy args for container image build
|
|
||||||
|
|
||||||
`pnpm install` inside `docker build` fails with `SELF_SIGNED_CERT_IN_CHAIN` because the sandbox's MITM proxy presents its own certificate. Add proxy build args to `container/Dockerfile`:
|
|
||||||
|
|
||||||
Add these lines after the `FROM` line:
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Accept proxy build args
|
|
||||||
ARG http_proxy
|
|
||||||
ARG https_proxy
|
|
||||||
ARG no_proxy
|
|
||||||
ARG NODE_EXTRA_CA_CERTS
|
|
||||||
ARG npm_config_strict_ssl=true
|
|
||||||
RUN npm config set strict-ssl ${npm_config_strict_ssl}
|
|
||||||
```
|
|
||||||
|
|
||||||
And after the `RUN pnpm install` line:
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
RUN npm config set strict-ssl true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4b. Build script — forward proxy args
|
|
||||||
|
|
||||||
Patch `container/build.sh` to pass proxy env vars to `docker build`:
|
|
||||||
|
|
||||||
Add these `--build-arg` flags to the `docker build` command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \
|
|
||||||
--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \
|
|
||||||
--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \
|
|
||||||
--build-arg npm_config_strict_ssl=false \
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4c. Container runner — proxy forwarding, CA cert mount, /dev/null fix
|
|
||||||
|
|
||||||
Three changes to `src/container-runner.ts`:
|
|
||||||
|
|
||||||
**Replace `/dev/null` shadow mount.** The sandbox rejects `/dev/null` bind mounts. Find where `.env` is shadow-mounted to `/dev/null` and replace it with an empty file:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Create an empty file to shadow .env (Docker Sandbox rejects /dev/null mounts)
|
|
||||||
const emptyEnvPath = path.join(DATA_DIR, 'empty-env');
|
|
||||||
if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, '');
|
|
||||||
// Use emptyEnvPath instead of '/dev/null' in the mount
|
|
||||||
```
|
|
||||||
|
|
||||||
**Forward proxy env vars** to spawned agent containers. Add `-e` flags for `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase variants.
|
|
||||||
|
|
||||||
**Mount CA certificate.** If `NODE_EXTRA_CA_CERTS` or `SSL_CERT_FILE` is set, copy the cert into the project directory and mount it into agent containers:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE;
|
|
||||||
if (caCertSrc) {
|
|
||||||
const certDir = path.join(DATA_DIR, 'ca-cert');
|
|
||||||
fs.mkdirSync(certDir, { recursive: true });
|
|
||||||
fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt'));
|
|
||||||
// Mount: certDir -> /workspace/ca-cert (read-only)
|
|
||||||
// Set NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt in the container
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4d. Container runtime — prevent self-termination
|
|
||||||
|
|
||||||
In `src/container-runtime.ts`, the `cleanupOrphans()` function matches containers by the `nanoclaw-` prefix. Inside a sandbox, the sandbox container itself may match (e.g., `nanoclaw-docker-sandbox`). Filter out the current hostname:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In cleanupOrphans(), filter out os.hostname() from the list of containers to stop
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4e. Credential proxy — route through MITM proxy
|
|
||||||
|
|
||||||
In `src/credential-proxy.ts`, upstream API requests need to go through the sandbox proxy. Add `HttpsProxyAgent` to outbound requests:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
|
||||||
|
|
||||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
|
|
||||||
const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
|
||||||
// Pass upstreamAgent to https.request() options
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4f. Setup script — proxy build args
|
|
||||||
|
|
||||||
Patch `setup/container.ts` to pass the same proxy `--build-arg` flags as `build.sh` (Step 4b).
|
|
||||||
|
|
||||||
## Step 5: Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm run build
|
|
||||||
bash container/build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 6: Add a Channel
|
|
||||||
|
|
||||||
### Telegram
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Apply the Telegram skill
|
|
||||||
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-telegram
|
|
||||||
|
|
||||||
# Rebuild after applying the skill
|
|
||||||
pnpm run build
|
|
||||||
|
|
||||||
# Configure .env
|
|
||||||
cat > .env << EOF
|
|
||||||
TELEGRAM_BOT_TOKEN=<your-token-from-botfather>
|
|
||||||
ASSISTANT_NAME=nanoclaw
|
|
||||||
ANTHROPIC_API_KEY=proxy-managed
|
|
||||||
EOF
|
|
||||||
mkdir -p data/env && cp .env data/env/env
|
|
||||||
|
|
||||||
# Register your chat
|
|
||||||
pnpm exec tsx setup/index.ts --step register \
|
|
||||||
--jid "tg:<your-chat-id>" \
|
|
||||||
--name "My Chat" \
|
|
||||||
--trigger "@nanoclaw" \
|
|
||||||
--folder "telegram_main" \
|
|
||||||
--channel telegram \
|
|
||||||
--assistant-name "nanoclaw" \
|
|
||||||
--is-main \
|
|
||||||
--no-trigger-required
|
|
||||||
```
|
|
||||||
|
|
||||||
**To find your chat ID:** Send any message to your bot, then:
|
|
||||||
```bash
|
|
||||||
curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot<TOKEN>/getUpdates" | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
**Telegram in groups:** Disable Group Privacy in @BotFather (`/mybots` > Bot Settings > Group Privacy > Turn off), then remove and re-add the bot.
|
|
||||||
|
|
||||||
**Important:** If the Telegram skill creates `src/channels/telegram.ts`, you'll need to patch it for proxy support. Add an `HttpsProxyAgent` and pass it to grammy's `Bot` constructor via `baseFetchConfig.agent`. Then rebuild.
|
|
||||||
|
|
||||||
### WhatsApp
|
|
||||||
|
|
||||||
Make sure you configured proxy bypass in [Step 1](#step-1-create-the-sandbox) first.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Apply the WhatsApp skill
|
|
||||||
pnpm exec tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
|
|
||||||
|
|
||||||
# Rebuild
|
|
||||||
pnpm run build
|
|
||||||
|
|
||||||
# Configure .env
|
|
||||||
cat > .env << EOF
|
|
||||||
ASSISTANT_NAME=nanoclaw
|
|
||||||
ANTHROPIC_API_KEY=proxy-managed
|
|
||||||
EOF
|
|
||||||
mkdir -p data/env && cp .env data/env/env
|
|
||||||
|
|
||||||
# Authenticate (choose one):
|
|
||||||
|
|
||||||
# QR code — scan with WhatsApp camera:
|
|
||||||
pnpm exec tsx src/whatsapp-auth.ts
|
|
||||||
|
|
||||||
# OR pairing code — enter code in WhatsApp > Linked Devices > Link with phone number:
|
|
||||||
pnpm exec tsx src/whatsapp-auth.ts --pairing-code --phone <phone-number-no-plus>
|
|
||||||
|
|
||||||
# Register your chat (JID = your phone number + @s.whatsapp.net)
|
|
||||||
pnpm exec tsx setup/index.ts --step register \
|
|
||||||
--jid "<phone>@s.whatsapp.net" \
|
|
||||||
--name "My Chat" \
|
|
||||||
--trigger "@nanoclaw" \
|
|
||||||
--folder "whatsapp_main" \
|
|
||||||
--channel whatsapp \
|
|
||||||
--assistant-name "nanoclaw" \
|
|
||||||
--is-main \
|
|
||||||
--no-trigger-required
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** The WhatsApp skill files (`src/channels/whatsapp.ts` and `src/whatsapp-auth.ts`) also need proxy patches — add `HttpsProxyAgent` for WebSocket connections and a proxy-aware version fetch. Then rebuild.
|
|
||||||
|
|
||||||
### Both Channels
|
|
||||||
|
|
||||||
Apply both skills, patch both for proxy support, combine the `.env` variables, and register each chat separately.
|
|
||||||
|
|
||||||
## Step 7: Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm start
|
|
||||||
```
|
|
||||||
|
|
||||||
You don't need to set `ANTHROPIC_API_KEY` manually. The sandbox proxy intercepts requests and replaces `proxy-managed` with your real key automatically.
|
|
||||||
|
|
||||||
## Networking Details
|
|
||||||
|
|
||||||
### How the proxy works
|
|
||||||
|
|
||||||
All traffic from the sandbox routes through the host proxy at `host.docker.internal:3128`:
|
|
||||||
|
|
||||||
```
|
|
||||||
Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → Host proxy → api.anthropic.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**"Bypass" does not mean traffic skips the proxy.** It means the proxy passes traffic through without MITM inspection. Node.js doesn't automatically use `HTTP_PROXY` env vars — you need explicit `HttpsProxyAgent` configuration in every HTTP/WebSocket client.
|
|
||||||
|
|
||||||
### Shared paths for DinD mounts
|
|
||||||
|
|
||||||
Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared":
|
|
||||||
- `/dev/null` → replace with an empty file in the project dir
|
|
||||||
- `/usr/local/share/ca-certificates/` → copy cert to project dir
|
|
||||||
- `/home/agent/` → clone to workspace instead
|
|
||||||
|
|
||||||
### Git clone and virtiofs
|
|
||||||
|
|
||||||
The workspace is mounted via virtiofs. Git's pack file handling can corrupt over virtiofs during clone. Workaround: clone to `/home/agent` first, then `mv` into the workspace.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### pnpm install fails with SELF_SIGNED_CERT_IN_CHAIN
|
|
||||||
```bash
|
|
||||||
npm config set strict-ssl false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Container build fails with proxy errors
|
|
||||||
```bash
|
|
||||||
docker build \
|
|
||||||
--build-arg http_proxy=$http_proxy \
|
|
||||||
--build-arg https_proxy=$https_proxy \
|
|
||||||
-t nanoclaw-agent:latest container/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Agent containers fail with "path not shared"
|
|
||||||
All bind-mounted paths must be under the workspace directory. Check:
|
|
||||||
- Is NanoClaw cloned into the workspace? (not `/home/agent/`)
|
|
||||||
- Is the CA cert copied to the project root?
|
|
||||||
- Has the empty `.env` shadow file been created?
|
|
||||||
|
|
||||||
### Agent containers can't reach Anthropic API
|
|
||||||
Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`.
|
|
||||||
|
|
||||||
### WhatsApp error 405
|
|
||||||
The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`.
|
|
||||||
|
|
||||||
### WhatsApp "Connection failed" immediately
|
|
||||||
Proxy bypass not configured. From the **host**, run:
|
|
||||||
```bash
|
|
||||||
docker sandbox network proxy <sandbox-name> \
|
|
||||||
--bypass-host web.whatsapp.com \
|
|
||||||
--bypass-host "*.whatsapp.com" \
|
|
||||||
--bypass-host "*.whatsapp.net"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Telegram bot doesn't receive messages
|
|
||||||
1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`)
|
|
||||||
2. Check Group Privacy is disabled in @BotFather if using in groups
|
|
||||||
|
|
||||||
### Git clone fails with "inflate: data stream error"
|
|
||||||
Clone to a non-workspace path first, then move:
|
|
||||||
```bash
|
|
||||||
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
|
|
||||||
```
|
|
||||||
|
|
||||||
### WhatsApp QR code doesn't display
|
|
||||||
Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`):
|
|
||||||
```bash
|
|
||||||
docker sandbox run shell-nanoclaw-workspace
|
|
||||||
# Then inside:
|
|
||||||
pnpm exec tsx src/whatsapp-auth.ts
|
|
||||||
```
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "2.1.34",
|
"version": "2.1.36",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="207k tokens, 104% of context window">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="208k tokens, 104% of context window">
|
||||||
<title>207k tokens, 104% of context window</title>
|
<title>208k tokens, 104% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">tokens</text>
|
<text x="26" y="14">tokens</text>
|
||||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">207k</text>
|
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">208k</text>
|
||||||
<text x="71" y="14">207k</text>
|
<text x="71" y="14">208k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the mount allowlist loader/validator.
|
||||||
|
*
|
||||||
|
* Covers the two cleanups:
|
||||||
|
* - The loader honors the per-root `readOnly` key (translating it to
|
||||||
|
* `allowReadWrite`) and tolerates the top-level `nonMainReadOnly` key that
|
||||||
|
* setup writes into every fresh install.
|
||||||
|
* - The allowlist is read per call (mtime-keyed cache), so a parse error is
|
||||||
|
* never cached permanently — a fixed file is picked up without a restart.
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// The config path is a module-level const in production; point it at a
|
||||||
|
// per-test temp file via a getter so each test is isolated from the cache.
|
||||||
|
const mockState = vi.hoisted(() => ({ allowlistPath: '' }));
|
||||||
|
|
||||||
|
vi.mock('../../config.js', async () => {
|
||||||
|
const actual = await vi.importActual<Record<string, unknown>>('../../config.js');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
get MOUNT_ALLOWLIST_PATH() {
|
||||||
|
return mockState.allowlistPath;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { loadMountAllowlist, validateMount } from './index.js';
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let configFile: string;
|
||||||
|
let projectsDir: string;
|
||||||
|
let repoDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mnt-sec-'));
|
||||||
|
configFile = path.join(tmpDir, 'mount-allowlist.json');
|
||||||
|
mockState.allowlistPath = configFile;
|
||||||
|
|
||||||
|
projectsDir = path.join(tmpDir, 'projects');
|
||||||
|
repoDir = path.join(projectsDir, 'repo');
|
||||||
|
fs.mkdirSync(repoDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeAllowlist(obj: unknown): void {
|
||||||
|
fs.writeFileSync(configFile, JSON.stringify(obj, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loadMountAllowlist', () => {
|
||||||
|
it('translates per-root readOnly:false into a read-write grant', () => {
|
||||||
|
writeAllowlist({
|
||||||
|
allowedRoots: [{ path: projectsDir, readOnly: false }],
|
||||||
|
blockedPatterns: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowlist = loadMountAllowlist();
|
||||||
|
expect(allowlist).not.toBeNull();
|
||||||
|
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(true);
|
||||||
|
|
||||||
|
// ...and a mount that requests read-write actually gets it.
|
||||||
|
const result = validateMount({ hostPath: repoDir, readonly: false });
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.effectiveReadonly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps readOnly:true as a read-only grant', () => {
|
||||||
|
writeAllowlist({
|
||||||
|
allowedRoots: [{ path: projectsDir, readOnly: true }],
|
||||||
|
blockedPatterns: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowlist = loadMountAllowlist();
|
||||||
|
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(false);
|
||||||
|
|
||||||
|
const result = validateMount({ hostPath: repoDir, readonly: false });
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.effectiveReadonly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates an unknown top-level nonMainReadOnly key', () => {
|
||||||
|
writeAllowlist({
|
||||||
|
allowedRoots: [{ path: projectsDir, allowReadWrite: true }],
|
||||||
|
blockedPatterns: [],
|
||||||
|
nonMainReadOnly: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowlist = loadMountAllowlist();
|
||||||
|
expect(allowlist).not.toBeNull();
|
||||||
|
expect(allowlist!.allowedRoots).toHaveLength(1);
|
||||||
|
expect(allowlist!.allowedRoots[0].allowReadWrite).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks up a fixed file without a restart (parse errors are not cached)', () => {
|
||||||
|
// A broken edit blocks all mounts...
|
||||||
|
fs.writeFileSync(configFile, 'not valid json {');
|
||||||
|
expect(loadMountAllowlist()).toBeNull();
|
||||||
|
|
||||||
|
// ...but fixing the file recovers on the very next call — no restart.
|
||||||
|
writeAllowlist({
|
||||||
|
allowedRoots: [{ path: projectsDir, allowReadWrite: true }],
|
||||||
|
blockedPatterns: [],
|
||||||
|
});
|
||||||
|
const allowlist = loadMountAllowlist();
|
||||||
|
expect(allowlist).not.toBeNull();
|
||||||
|
expect(allowlist!.allowedRoots).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the allowlist file is missing', () => {
|
||||||
|
// No file written.
|
||||||
|
expect(loadMountAllowlist()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,9 +29,11 @@ export interface AllowedRoot {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the allowlist in memory - only reloads on process restart
|
// Cache the last successfully-parsed allowlist, keyed on the file's path +
|
||||||
let cachedAllowlist: MountAllowlist | null = null;
|
// mtime. A changed or fixed file is picked up on the next call (no restart),
|
||||||
let allowlistLoadError: string | null = null;
|
// and a parse error is never cached permanently — one bad edit blocks mounts
|
||||||
|
// only until the file is fixed.
|
||||||
|
let cache: { path: string; mtimeMs: number; allowlist: MountAllowlist } | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default blocked patterns - paths that should never be mounted
|
* Default blocked patterns - paths that should never be mounted
|
||||||
@@ -57,60 +59,108 @@ const DEFAULT_BLOCKED_PATTERNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the mount allowlist from the external config location.
|
* Normalize a raw allowed-root entry into an {@link AllowedRoot}.
|
||||||
* Returns null if the file doesn't exist or is invalid.
|
*
|
||||||
* Result is cached in memory for the lifetime of the process.
|
* The read-only decision is per-root. Historically this validator only read
|
||||||
|
* `allowReadWrite`, but the /manage-mounts skill and setup write `readOnly`
|
||||||
|
* instead — so a `readOnly: false` grant was silently forced read-only.
|
||||||
|
* Translate `readOnly` → `allowReadWrite = !readOnly` (with a warning) unless an
|
||||||
|
* explicit `allowReadWrite` is already present. With neither key, default to
|
||||||
|
* read-only (fail safe).
|
||||||
*/
|
*/
|
||||||
export function loadMountAllowlist(): MountAllowlist | null {
|
function normalizeRoot(root: Record<string, unknown>): AllowedRoot {
|
||||||
if (cachedAllowlist !== null) {
|
const rootPath = typeof root.path === 'string' ? root.path : '';
|
||||||
return cachedAllowlist;
|
const description = typeof root.description === 'string' ? root.description : undefined;
|
||||||
|
|
||||||
|
let allowReadWrite: boolean;
|
||||||
|
if (typeof root.allowReadWrite === 'boolean') {
|
||||||
|
allowReadWrite = root.allowReadWrite;
|
||||||
|
} else if (typeof root.readOnly === 'boolean') {
|
||||||
|
allowReadWrite = !root.readOnly;
|
||||||
|
log.warn('Mount allowlist root uses "readOnly" — translating to allowReadWrite', {
|
||||||
|
root: rootPath,
|
||||||
|
readOnly: root.readOnly,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
allowReadWrite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowlistLoadError !== null) {
|
return { path: rootPath, allowReadWrite, description };
|
||||||
// Already tried and failed, don't spam logs
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the mount allowlist from the external config location.
|
||||||
|
* Returns null if the file doesn't exist or is invalid.
|
||||||
|
* Re-reads on every call, but serves from an in-memory cache while the file's
|
||||||
|
* mtime is unchanged. A parse error is never cached — fix the file and the next
|
||||||
|
* call recovers without a service restart.
|
||||||
|
*/
|
||||||
|
export function loadMountAllowlist(): MountAllowlist | null {
|
||||||
|
// Missing-file behavior: warn and block additional mounts, but do NOT cache
|
||||||
|
// the miss — the file may be created later without a restart.
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(MOUNT_ALLOWLIST_PATH);
|
||||||
|
} catch {
|
||||||
|
log.warn(
|
||||||
|
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
|
||||||
|
{ path: MOUNT_ALLOWLIST_PATH },
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Serve from cache only while the same file is unchanged since the last
|
||||||
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
|
// successful load. Any edit (including fixing a previously broken file) bumps
|
||||||
// Do NOT cache this as an error — file may be created later without restart.
|
// the mtime and is picked up on the next call.
|
||||||
// Only parse/structural errors are permanently cached.
|
if (cache !== null && cache.path === MOUNT_ALLOWLIST_PATH && cache.mtimeMs === stat.mtimeMs) {
|
||||||
log.warn(
|
return cache.allowlist;
|
||||||
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
|
}
|
||||||
{ path: MOUNT_ALLOWLIST_PATH },
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
|
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
|
||||||
const allowlist = JSON.parse(content) as MountAllowlist;
|
const raw = JSON.parse(content) as Record<string, unknown>;
|
||||||
|
|
||||||
// Validate structure
|
// Validate structure
|
||||||
if (!Array.isArray(allowlist.allowedRoots)) {
|
if (!Array.isArray(raw.allowedRoots)) {
|
||||||
throw new Error('allowedRoots must be an array');
|
throw new Error('allowedRoots must be an array');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(allowlist.blockedPatterns)) {
|
if (!Array.isArray(raw.blockedPatterns)) {
|
||||||
throw new Error('blockedPatterns must be an array');
|
throw new Error('blockedPatterns must be an array');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge with default blocked patterns
|
// Warn-and-ignore the top-level `nonMainReadOnly` key. Setup writes it into
|
||||||
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
|
// every fresh install, but this validator has no concept of a "main" agent —
|
||||||
allowlist.blockedPatterns = mergedBlockedPatterns;
|
// read-only is decided per-root. Do NOT throw: a hard reject would fail
|
||||||
|
// closed and brick all mounts on a standard install.
|
||||||
|
if ('nonMainReadOnly' in raw) {
|
||||||
|
log.warn('Mount allowlist has unsupported top-level "nonMainReadOnly" key — ignoring (read-only is per-root)', {
|
||||||
|
path: MOUNT_ALLOWLIST_PATH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
cachedAllowlist = allowlist;
|
const allowedRoots = (raw.allowedRoots as Array<Record<string, unknown>>).map(normalizeRoot);
|
||||||
|
|
||||||
|
// Merge with default blocked patterns
|
||||||
|
const blockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...(raw.blockedPatterns as string[])])];
|
||||||
|
|
||||||
|
const allowlist: MountAllowlist = { allowedRoots, blockedPatterns };
|
||||||
|
|
||||||
|
cache = { path: MOUNT_ALLOWLIST_PATH, mtimeMs: stat.mtimeMs, allowlist };
|
||||||
log.info('Mount allowlist loaded successfully', {
|
log.info('Mount allowlist loaded successfully', {
|
||||||
path: MOUNT_ALLOWLIST_PATH,
|
path: MOUNT_ALLOWLIST_PATH,
|
||||||
allowedRoots: allowlist.allowedRoots.length,
|
allowedRoots: allowlist.allowedRoots.length,
|
||||||
blockedPatterns: allowlist.blockedPatterns.length,
|
blockedPatterns: allowlist.blockedPatterns.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
return cachedAllowlist;
|
return allowlist;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
allowlistLoadError = err instanceof Error ? err.message : String(err);
|
// Do NOT poison the cache — a corrupt edit blocks mounts only until it's
|
||||||
|
// fixed, then the next call re-reads and recovers.
|
||||||
|
cache = null;
|
||||||
log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', {
|
log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', {
|
||||||
path: MOUNT_ALLOWLIST_PATH,
|
path: MOUNT_ALLOWLIST_PATH,
|
||||||
error: allowlistLoadError,
|
error: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user