- JavaScript 90.9%
- Shell 9.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
- Operations can specify agents[] to restrict which agents can use them - Unscoped operations remain available to all agents (backward compatible) - Proxmox VM/CT management scoped to specs only |
||
| deploy | ||
| scripts | ||
| src | ||
| .gitignore | ||
| drawbridge.example.yaml | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| TEST-PLAN.md | ||
Drawbridge
Permissions gateway for AI agents operating on self-hosted infrastructure.
Drawbridge is a lightweight HTTP service that mediates between AI agents and privileged operations (Docker, system commands, service management). Agents never get direct access to Docker sockets or sudo — they request operations through Drawbridge, which validates against an allowlist, optionally requires human approval, and maintains a full audit trail.
Inspired by the Permissions Vending Machine pattern from the ACP platform, adapted for self-hosted homelab infrastructure instead of AWS IAM.
The Problem
AI agents with Docker group membership have root-equivalent access to the host. They can:
docker execinto any container (read secrets, modify configs, restart services)docker run -v /:/hostto mount the entire host filesystem as root- Access password vaults, auth databases, camera feeds, and personal documents
- Bypass all sudo restrictions via container escape
Sudo whitelists are meaningless when Docker group membership exists.
Architecture
┌─────────────────────────────────────────────────────────┐
│ OpenClaw Host │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Agent A │ │ Agent B │ │ Agent C │ │
│ │ (token) │ │ (token) │ │ (token) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └──────────────┼──────────────┘ │
│ │ HTTP + Bearer auth │
│ ┌───────▼────────┐ │
│ │ Drawbridge │ │
│ │ :7600 │ │
│ │ │ │
│ │ ┌───────────┐ │ │
│ │ │ Allowlist │ │ ┌──────────────────┐ │
│ │ │ Engine │ │ │ Audit Log │ │
│ │ └───────────┘ │ │ (SQLite) │ │
│ │ ┌───────────┐ │ └──────────────────┘ │
│ │ │ Rate │ │ ┌──────────────────┐ │
│ │ │ Limiter │ │ │ Notifier │ │
│ │ └───────────┘ │ │ (Zulip/Discord) │ │
│ └───────┬────────┘ └──────────────────┘ │
│ │ SSH (dedicated keypair) │
└──────────────────────┼───────────────────────────────────┘
│
┌────────▼────────┐
│ Docker hosts │
│ (remote VMs) │
└─────────────────┘
Features
Tiered Operations
| Tier | Approval | Examples |
|---|---|---|
| Open | Auto-approved | docker ps, docker logs, docker inspect, df -h, free -h |
| Standard | Auto-approved with rate limits | docker restart, systemctl restart, app management commands |
| Sensitive | Human approval required | docker exec (any), reboot, config modifications |
| Petition | Human approval + catalogue decision | Any command not in the allowlist |
Allowlist (not blocklist)
Every permitted operation is explicitly listed. If it's not in the allowlist, it's denied.
operations:
- pattern: "docker ps"
tier: open
- pattern: "docker restart {container}"
tier: standard
rateLimit: "5/hour"
- pattern: "docker exec myapp /app/manage.py {subcommand}"
tier: standard
allowedArgs:
subcommand: ["migrate", "check"]
- pattern: "docker exec {container} {command}"
tier: sensitive
denyContainers:
- "vaultwarden"
- "authelia"
denyPatterns:
- "docker run *"
- "docker create *"
- "docker cp *"
Hard Denials (never allowed, no override)
docker run/docker create/docker cp/docker build(container creation, file copy)- Any operation on security-critical containers (password vaults, auth gateways)
- Host filesystem mounts
Per-Agent Authentication
Each agent gets a unique Bearer token. Tokens are validated on every request, and agents cannot impersonate each other — the token must match the claimed agent ID.
curl -X POST http://localhost:7600/ops/request \
-H "Authorization: Bearer db_your_agent_token" \
-H "Content-Type: application/json" \
-d '{"agent":"main","host":"docker-host-1","command":"docker restart myapp","reason":"health check failed"}'
Generate tokens: node -e "console.log('db_' + require('crypto').randomBytes(16).toString('hex'))"
Zulip Notifications
Sensitive tier requests and petitions trigger Zulip notifications with approve/deny instructions:
🏰 Drawbridge — Approval Required
Request:
req_abc123Agent:mainHost:docker-host-1Command:docker exec myapp cat /etc/configReason: checking configuration Tier: sensitiveReact ✅ to approve, ❌ to deny.
Petitions
When an agent needs an operation not in the catalogue:
curl -X POST http://localhost:7600/ops/petition \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"agent":"specs","host":"docker-host-1","command":"apt list --upgradable","reason":"security check","suggestedTier":"open"}'
Petitions always require human approval. The approval response includes a catalogue decision:
- Allow once — execute, don't add to catalogue
- Allow and catalogue — execute and add to allowlist
- Deny — reject
- Deny and block — reject and add to deny list
Rate limited to 5 petitions/agent/day.
Config Hot-Reload
Edit drawbridge.yaml and changes take effect within 5 seconds — no restart needed. Allowlist, hosts, notifications, and agent tokens all reload automatically.
Request Expiration
Pending approval requests and petitions auto-expire after a configurable timeout (default: 15 minutes).
API
| Endpoint | Method | Description |
|---|---|---|
/ops/request |
POST | Submit an operation for execution |
/ops/status/:id |
GET | Check status of a request |
/ops/approve/:id |
POST | Approve a pending request |
/ops/deny/:id |
POST | Deny a pending request |
/ops/audit |
GET | Query audit log (filter by agent, host, since) |
/ops/stats |
GET | Dashboard summary (counts by status, agent, tier) |
/ops/petition |
POST | Request an unlisted operation |
/ops/petitions |
GET | List petitions (filter by status) |
/ops/petition/:id/decide |
POST | Decide on a petition (allow_once, allow_catalogue, deny, deny_block) |
/health |
GET | Health check (no auth required) |
Response Codes
| Status | Meaning |
|---|---|
200 executed |
Command ran successfully |
200 failed |
Command ran but exited non-zero |
202 pending_approval |
Sensitive tier — waiting for human approval |
403 denied |
Not in allowlist |
403 hard_denied |
Matches deny pattern or denied container |
429 rate_limited |
Too many requests for this operation |
401 |
Missing or invalid auth token |
Stack
- Runtime: Node.js (Express)
- Database: SQLite via better-sqlite3 (audit log, pending requests, petitions)
- Config: YAML allowlist (
drawbridge.yaml) with hot-reload - Execution: SSH to target hosts (configurable keypair) or local Docker socket
- Notifications: Zulip (direct API)
- Auth: Per-agent Bearer tokens
- Deployment: systemd service or nohup + cron
Quick Start
# Clone and install
git clone <repo-url> drawbridge
cd drawbridge
npm ci
# Configure
cp drawbridge.example.yaml drawbridge.yaml
# Edit drawbridge.yaml: add your hosts, SSH key path, agent tokens, Zulip credentials
# Run
node src/index.js drawbridge.yaml
# Or use the control script
bash scripts/drawbridge-ctl.sh start
Security Model
What Drawbridge Stops
- Casual privilege use. Agents use Drawbridge because it's the documented path and easier than raw SSH.
- Accidental damage. Rate limits, allowlists, and tier classification prevent destructive loops.
- Audit gaps. Every privileged operation is logged with agent, command, reason, and outcome.
- Prompt injection escalation. Compromised agents are limited by the allowlist — sensitive ops require human approval, hard denials can't be overridden.
- Agent impersonation. Per-agent tokens prevent one agent from acting as another.
Defense in Depth
Level 1 — Convention (Drawbridge as-is): Agents are instructed to use Drawbridge. All ops are audited. This handles 95% of the risk (accidental, not adversarial).
Level 2 — Filesystem hardening: Run Drawbridge as a dedicated system user. SSH key, config, and audit DB owned by root or the service user. Agents can't read the key, edit the config, or tamper with logs.
Level 3 — Network isolation: Remove agent SSH access to Docker hosts entirely. Only Drawbridge's service user has SSH keys. Agents can only reach Drawbridge's HTTP API.
Level 4 — Process isolation (OpenClaw sandbox): Each agent runs in its own Docker container via OpenClaw's built-in sandbox system. Custom Docker network routes only to Drawbridge. Full filesystem and network isolation between agents.
{
"agents": {
"defaults": {
"sandbox": {
"mode": "all",
"backend": "docker",
"scope": "agent",
"docker": { "network": "drawbridge-only" }
}
}
}
}
Immediate Mitigation (before Drawbridge)
Close the Docker group hole with sudoers rules:
- Remove the agent user from the Docker group on all hosts
- Create
/etc/sudoers.d/agent-docker:
agent ALL=(root) NOPASSWD: /usr/bin/docker ps *
agent ALL=(root) NOPASSWD: /usr/bin/docker ps
agent ALL=(root) NOPASSWD: /usr/bin/docker logs *
agent ALL=(root) NOPASSWD: /usr/bin/docker inspect *
agent ALL=(root) NOPASSWD: /usr/bin/docker stats --no-stream *
agent ALL=(root) NOPASSWD: /usr/bin/docker restart *
No docker run, no docker exec with arbitrary commands, no volume mounts.
License
MIT