Skip to Content
ConceptsSecurity Model

Security Model

RantAIClaw runs autonomous agents that touch real systems. Since v0.6.89 the security model splits into two postures by surface class — usable by default where the agent operates locally, deny-by-default where the outside world can reach it:

  • Usable by default at the local-capability surface. Local tools ship enabled — web_search, browser, and http_request (default allowed_domains = ["*"]) — and block_high_risk_commands defaults to false, so a fresh install can search, fetch URLs, and run shell commands without hand-editing config first. High-risk commands are not pre-blocked, but they still pass the command allowlist and the approval gate.
  • Deny by default at the exposure boundary. Anything that lets the network reach the agent stays locked: the gateway binds 127.0.0.1 only, a public bind without allow_public_bind does not start, pairing is required, and rate limits apply. Channels with empty allowlists are offline. Forbidden paths are unconditional.
  • Least privilege. Sandboxes detect and apply automatically per platform. The narrowest available sandbox runs.
  • Explicit approval. Tool calls go through the approval gate. Tools not in auto_approve are denied or prompted.
  • Auditable. Every approval decision and tool execution lands in an append-only JSONL log.

This page maps those rules to the code that enforces them. Where I make a claim about behavior, the file path is at the bottom of each section. Defaults are defined in src/config/schema.rs.

Threat model

RantAIClaw assumes a hostile network and a moderately-trusted operator. The hostile network can attempt to forge channel webhooks, replay requests, brute-force gateway tokens, or exfiltrate via misconfigured allowlists. The operator is trusted to set initial config but not to remember every constraint — so the runtime enforces hard boundaries the operator cannot accidentally weaken without an explicit, visible config flag.

What is not in the threat model: a malicious operator with shell access. If you give an agent root, the agent has root.

The approval gate

Every tool call passes through src/approval/mod.rs. The gate combines:

  • the runtime [autonomy].level (readonly / supervised / full)
  • the auto_approve and always_ask lists
  • forbidden paths (unconditional) and block_high_risk_commands (default false, an optional hard stop)
  • for shell, the patterns in <profile>/policy/command_allowlist.toml

See Autonomy Levels for the full model.

Sandbox auto-detection

Sandbox selection is automatic based on what the host supports (src/security/detect.rs). Priority order:

  1. Landlock (Linux ≥ 5.13) — preferred when available. Compiled in unconditionally on Linux.
  2. Firejail — used if installed.
  3. Bubblewrap — feature-gated (sandbox-bubblewrap); on macOS this is the only sandbox path.
  4. Docker — cross-platform fallback.
  5. NoopSandbox — last resort.

Notes:

  • Landlock requires no Cargo feature flag — it’s compiled in via [target.'cfg(target_os = "linux")'.dependencies] landlock = "0.4". The historical sandbox-landlock flag is now a no-op kept for backward compatibility.
  • Bubblewrap requires --features sandbox-bubblewrap at build time.
  • Windows has no sandbox path — falls through to NoopSandbox.
  • macOS has no native Apple sandbox path — only Bubblewrap (feature) and Docker.

To disable sandboxing entirely:

[security.sandbox] enabled = false # or backend = "none"

There is no RANTAICLAW_ALLOW_UNSANDBOXED env var. (The env var RANTAICLAW_ALLOW_PUBLIC_BIND=1 exists, but for gateway bind, not sandbox bypass.)

Allowlists, not blocklists

For things the agent decides to talk to (channels, networks, commands), the runtime asks for allowlists rather than blocklists:

  • Channels. Most channels treat empty allowlist as offline. The exception is email_channel / linq, which allow-all on empty allowed_senders. See Channels & Providers for per-channel allowlist key names.
  • Commands. The command_allowlist.toml patterns are quote-aware globs over the command string. Defense-in-depth shell parsing strips subshells, redirects, env-prefixes, etc., before matching.
  • Forbidden paths. Default forbidden_paths includes /home, /tmp, /var, ~/.config, ~/.aws. Combined with workspace_only = true (default), this confines file tools to workspace_dir.

block_high_risk_commands (default false) is an optional hard stop you can turn on: when set to true, commands like rm, sudo, curl, wget, ssh, chmod, mkfs, iptables, mount are denied even under [autonomy].level = "full". Left at its default (false), those commands are not pre-blocked — they still pass the command allowlist and the approval gate like any other command.

The secret store

API keys, channel tokens, OAuth refresh credentials, and webhook secrets are encrypted with ChaCha20-Poly1305 AEAD.

The encryption key lives at:

~/.rantaiclaw/.secret_key (mode 0600 on Unix)

Generated on first encrypt via OsRng. There is no separate secrets.enc blob file — encrypted ciphertext is stored inline in config.toml as enc2:<hex(nonce ‖ ct ‖ tag)> strings. Reading the config decrypts these; writing re-encrypts.

Legacy enc:<...> XOR-cipher values from earlier versions are auto-migrated to enc2: on first read.

If you set [secrets].encrypt = false (the “sovereign user” toggle), values are stored plaintext — useful for short-lived ephemeral runs, dangerous in production.

OAuth tokens for MCP integrations live separately at:

~/.rantaiclaw/profiles/<active>/secrets/api_keys.toml (mode 0600)

This is a plain TOML file, not the AEAD store. It’s written by the OAuth flow during interactive setup.

Webhook authentication — three flavors

Different gateway endpoints use different verification:

  • POST /webhook (generic prompt entry) — bearer token + optional X-Webhook-Secret header (server SHA-256-hashes the value, compares with constant_time_eq). This is a shared-secret check, not an HMAC over the body.
  • Channel-specific endpoints — body HMAC:
    • POST /whatsappX-Hub-Signature-256: sha256=<hex>, HMAC-SHA256 over body, verified with mac.verify_slice (constant-time)
    • POST /linq — channel signing-secret, HMAC-SHA256
    • POST /nextcloud-talkX-Nextcloud-Talk-Random + X-Nextcloud-Talk-Signature headers
  • POST /triggers/{*path} (skill triggers) — bearer token only; no HMAC

The header names for HMAC endpoints are hard-coded per channel, not user-configurable. Missing or invalid signatures return 401 with a JSON body {"error": "..."}.

See Gateway for the full endpoint table.

Pairing tokens

Bearer tokens are 256-bit (64 hex chars), generated by POST /pair after submitting a one-time 6-digit pairing code printed at gateway startup. Tokens are stored as SHA-256 hashes in [gateway].paired_tokens in config.toml — only the hash, never the plaintext. Plaintext is returned exactly once from /pair.

There are no token scopes — auth is flat. Pair again to issue a new token.

On-demand / universal pairing

Beyond the gateway’s startup code, operators can mint pairing codes on demand — no daemon restart (added in v0.6.82):

rantaiclaw channels pair [--channel <name>] [--ttl <min>] [--max-uses <n>] [--no-owner]

This writes a time-windowed code into a shared on-disk store (--channel defaults to telegram, --ttl to 15 minutes; --max-uses is unlimited unless set). A running daemon picks it up on the next self-onboarding message with no restart. From chat, an owner can mint one with the owner-only issue_pairing_code tool; the TUI exposes /pair.

Codes are redeemed in-chat on every multi-user channel:

  • /bind <code> — grants the sender chat access (appends their native identity to that channel’s allowlist).
  • /claim <code> — same, but also makes the sender an approval owner (appends to approval_owners).

Security properties (src/security/pairing_store.rs, src/channels/pairing.rs):

  • Surface-scoped — a code minted for one channel can’t be claimed on another.
  • SHA-256-hashed at rest in a 0600 store — the plaintext code is never persisted.
  • Bounded by TTL + max-uses — a code expires at its window end or once its claim budget is spent.
  • issue_pairing_code is owner-only (GuestGate::OWNER_ONLY_TOOLS), so guests cannot mint codes. No config.toml schema change is involved.

Bind safety

Gateway defaults to 127.0.0.1. Public bind (0.0.0.0 or any non-loopback host) requires allow_public_bind = true:

[gateway] host = "0.0.0.0" port = 3000 allow_public_bind = true

Without that, the gateway refuses to start. Exception: if [tunnel].provider != "none" (cloudflared, ngrok configured), the public-bind guard is bypassed because the tunnel is expected to gate access.

The env var RANTAICLAW_ALLOW_PUBLIC_BIND=1 works as an alternative to the config flag.

Rate limits and idempotency

Sliding-window per-client-IP rate limits:

EndpointDefault
POST /pair10/min
POST /webhook, POST /triggers/*60/min

Idempotency: X-Idempotency-Key on /webhook deduplicates retries within dedupe_ttl_seconds (default 300).

Audit log

Path: <profile>/audit.log — one JSON object per line, append-only.

Schema (AuditEvent in src/security/audit.rs):

{ "timestamp": "2026-05-08T13:42:11.337Z", "event_id": "<uuid-v4>", "event_type": "command_execution", "actor": { "channel": "cli", "user_id": "...", "username": "..." }, "action": { "command": "cargo test --lib", "risk_level": "low", "approved": true, "allowed": true }, "result": { "success": true, "exit_code": 0, "duration_ms": 4421, "error": null }, "security": { "policy_violation": false, "rate_limit_remaining": 199, "sandbox_backend": "landlock" } }

event_type enum: command_execution, file_access, config_change, auth_success, auth_failure, policy_violation, security_event.

Rotation: when the file reaches max_size_mb (default 100), it rotates audit.logaudit.log.1.log.2.log, up to .10.log.

Two caveats: the audit serializer does not currently apply secret redaction to the command field (the redact() helper exists but is not wired into audit serialization). HMAC signing of audit events is also not implemented (the sign_events config flag is unused). Don’t rely on the audit log being secret-safe or tamper-evident yet.

Resource limits

[security.resources] max_memory_mb = 512 max_cpu_time_seconds = 60 max_subprocesses = 10 memory_monitoring = true

These limit shell-spawned subprocesses, not the agent’s own process.

What is not relaxed by autonomy

Some boundaries are unconditional. No autonomy level / preset relaxes them:

  • forbidden paths
  • TLS verification on outbound calls
  • gateway’s allow_public_bind requirement (localhost bind, pairing, rate limits)
  • channel allowlists (per-channel allowed_* keys)
  • HMAC verification on channel webhooks (when configured)

block_high_risk_commands is a separate, operator-set hard stop (default false): once you turn it on, no autonomy level can bypass it. It is opt-in rather than unconditional, because the local-capability posture is usable-by-default (see the thesis above).

Relaxing an exposure boundary requires editing its specific config field. The autonomy knob is not a backdoor.

Reading the code

  • src/security/policy.rs — autonomy enum, command parser, risk classifier, forbidden_path enforcement
  • src/security/detect.rs — sandbox auto-detection
  • src/security/landlock.rs — Landlock LSM wrapper
  • src/security/secrets.rs — AEAD secret store
  • src/security/audit.rs — append-only log + rotation
  • src/security/pairing.rs — paired-token issuance and verification
  • src/gateway/mod.rs — bind safety, webhook auth, rate limits, idempotency
  • src/approval/mod.rs — approval gate
Last updated on