Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.feral.sh/llms.txt

Use this file to discover all available pages before exploring further.

Status: Stable — BlindVault, permission tiers, and execution sandboxing are production-ready.

Security Model

FERAL assumes the LLM is untrusted. Credentials, tool execution, and autonomy are all gated through layered security primitives that prevent prompt injection from escalating into real-world damage.

BlindVault

The BlindVault stores all secrets (API keys, OAuth tokens, database passwords) encrypted at rest in ~/.feral/credentials.enc (mode 0600). Encryption is ChaCha20-Poly1305 (AEAD) with a key derived from your master passphrase via Argon2id; the derived key is cached in your OS keychain (macOS Keychain / GNOME Keyring / Windows Credential Manager) so the vault unlocks transparently on brain start. The LLM never sees raw credential values — the vault injects them at the HTTP layer right before a request leaves the process.
from feral_core.security import BlindVault

vault = BlindVault()
vault.store("weather_api", "sk-abc123...")

# When a skill fires, the vault injects the key:
headers = vault.inject("weather_api", {"X-API-Key": "$CREDENTIAL"})
# headers == {"X-API-Key": "sk-abc123..."}
The LLM sees only a placeholder like [CREDENTIAL:weather_api] in tool descriptions. Even if the model tries to exfiltrate it, the raw value is never in its context window.

Vault Storage

Credentials are stored during setup (feral setup) and persisted to ~/.feral/credentials.enc. The vault injects secrets at the HTTP layer — the LLM never sees raw values.

Permission Tiers

Every tool is tagged with a PermissionTier that determines what approval is needed before execution.
TierAuto-execute?Examples
passiveAlwaysRead memory, search web, get weather
activeIn hybrid/looseSend a message, create a file
privilegedOnly in looseRun shell command, install package
dangerousNever autoDelete files, send money, modify system config
Tiers are declared in tool definitions:
from feral_core.security import PermissionTier

@feral_tool(
    description="Delete a file from the filesystem",
    permission=PermissionTier.DANGEROUS,
)
async def delete_file(self, path: str) -> dict:
    ...

ExecutionSandbox

Tools tagged privileged or above run inside an ExecutionSandbox that constrains what the subprocess can do.
from feral_core.security import ExecutionSandbox

sandbox = ExecutionSandbox(
    allow_network=False,
    allow_fs_write=["/tmp/feral-scratch"],
    max_runtime_seconds=30,
    max_memory_mb=256,
)
result = await sandbox.run(["python3", "untrusted_script.py"])
The sandbox uses OS-level isolation (seccomp on Linux, sandbox-exec on macOS) plus a process timeout. WASM skills get Wasmtime’s capability-based sandbox automatically.

Autonomy Levels

FERAL supports three autonomy modes that control how the PermissionTier system gates execution. See the Autonomy Levels guide for full details.
ModeBehavior
strictEvery tool call requires user approval
hybridpassive + active auto-execute; privileged + dangerous ask first
looseEverything except dangerous auto-executes
Set via environment variable or config:
export FERAL_AUTONOMY=hybrid
// ~/.feral/settings.json
{ "autonomy": { "mode": "hybrid" } }

SandboxPolicy Files

For fine-grained control, drop a YAML or JSON policy file in ~/.feral/policies/:
# ~/.feral/policies/production.yaml
name: production
autonomy: hybrid

sandbox:
  allow_network: true
  allow_fs_write:
    - /tmp/feral-scratch
    - ~/.feral/memory.db
  max_runtime_seconds: 60
  max_memory_mb: 512

tool_overrides:
  shell_exec:
    permission: dangerous
  web_search:
    permission: passive
  send_email:
    permission: privileged
    require_confirmation_body: true
Load a named policy at startup:
feral start --policy production
Policies are composable — you can layer a base policy with per-session overrides:
from feral_core.security import SandboxPolicy

base = SandboxPolicy.load("production")
session_policy = base.overlay({
    "sandbox": {"allow_network": False},
    "tool_overrides": {"shell_exec": {"permission": "privileged"}},
})

Dangerous-Tool Deny Lists

Even in loose mode, certain tools are always gated. The dangerous_tools surface deny list is hard-coded and cannot be overridden by policy files:
DANGEROUS_TOOLS_DENY_LIST = [
    "delete_all_memory",
    "wipe_database",
    "send_payment",
    "modify_system_files",
    "disable_security",
]
You can extend (but never shrink) this list in config:
// ~/.feral/settings.json
{
  "dangerous_tools_extra": [
    "deploy_production",
    "revoke_all_tokens"
  ]
}

enforce_safety

The enforce_safety() function runs before every tool execution. It checks:
  1. The tool’s PermissionTier against the current autonomy level.
  2. Whether the tool is on the deny list.
  3. Whether a SandboxPolicy restricts the action.
  4. Whether a standing approval exists (see Autonomy Levels).
from feral_core.security import enforce_safety

allowed, reason = await enforce_safety(
    tool_name="shell_exec",
    args={"command": "rm -rf /tmp/old-cache"},
    session=current_session,
)
if not allowed:
    await request_approval(tool_name, args, reason)
If the check fails, the orchestrator pauses execution and surfaces an approval request to the user via the active channel (web UI, CLI, Telegram, etc.).

Hardening (v1.2.1)

The v1.2.1 release addressed a comprehensive security audit. All fixes are defense-in-depth measures that apply by default — no configuration required.

Path Traversal Protection

The catch-all route serving the WebUI static files now uses Path.resolve() followed by is_relative_to() to reject any request that escapes the static directory (e.g. ../../etc/passwd).

SQL Injection Whitelist on Sync

The P2P sync engine only accepts operations targeting a hardcoded whitelist of table names: notes, episodes, conversations, knowledge, wiki_pages. Any other table name is rejected before query construction.

CORS Restricted to Localhost

The default CORS allow_origins was changed from wildcard * to localhost:5173,localhost:9090. Production deployments should set explicit origins via configuration.

XSS Sanitization via DOMPurify

The server-driven UI (SDUI) renderer in the React client now passes all HTML content through DOMPurify before rendering. This prevents any LLM-generated or server-pushed markup from executing scripts.

Docker Sandbox Host Fallback Disabled

When the Docker sandbox cannot connect to the Docker daemon, it now raises an error instead of falling back to direct host execution. This prevents a missing Docker installation from silently bypassing container isolation.

Docker Sandbox Runtime Hardening

Every container started by security/docker_sandbox.py is launched with these defaults — no configuration needed:
  • --cap-drop ALL — every Linux capability is dropped.
  • --security-opt no-new-privileges — execve cannot raise privileges (blocks setuid escapes).
  • --pids-limit 128 — caps process fan-out so a fork-bomb cannot exhaust the host PID namespace.
  • --read-only root filesystem with a tmpfs mount at /tmp (nosuid, 128 MB).
  • --network none unless the caller explicitly requests network access.
  • Runs as the unprivileged sandbox user inside the image.
  • Optional seccomp profile via FERAL_SANDBOX_SECCOMP_PROFILE (the literal value unconfined is rejected — supply a JSON profile path or leave unset).
Tune any of these via the environment variables documented in Environment Variables → Docker sandbox hardening. The minimum effective pids-limit is 16 regardless of what you set.

Command Injection Blocked in Direct Execution

The daemon’s direct execution path no longer passes raw strings to a shell. Shell metacharacters and injection patterns are rejected by a safety filter before any command is executed.

Default Bind Address: 127.0.0.1

The FERAL_HOST default was changed from 0.0.0.0 (all interfaces) to 127.0.0.1 (loopback only). This prevents accidental exposure on public networks. Set FERAL_HOST=0.0.0.0 explicitly if you need LAN/remote access.

NODE_API_KEY Requires Explicit Configuration

The WebSocket authentication token (NODE_API_KEY) no longer ships with a default value. If unset, daemon-to-brain authentication is effectively disabled — you must configure it explicitly for any multi-node deployment.

API Keys in Headers, Not URL Parameters

The Gemini API key is now sent via the x-goog-api-key request header instead of as a URL query parameter. This prevents key leakage in server logs, browser history, and referrer headers.

Tool Safety: CONFIRM Before AUTO

The tool safety classifier now checks CONFIRM patterns before AUTO patterns. Previously, a tool matching both lists would be auto-executed; now it requires user confirmation first.

Limitations and Caveats

What FERAL Does NOT Protect Against

  • Physical access attacks: If someone has physical access to the machine running the Brain, they have access to all data.
  • Supply chain attacks: Third-party LLM providers can see your prompts (use Ollama for full local processing).
  • Side-channel attacks: The timing and size of WebSocket messages may reveal information about your activity.
  • Compromised LLM: If the LLM provider is compromised, tool calls may be manipulated.

Platform Differences

  • macOS: Full Accessibility permissions required for desktop automation. Gatekeeper may block unsigned daemons.
  • Linux: Docker required for code interpreter sandboxing. X11/Wayland differences affect screen capture.
  • Windows: Limited support. No systemd daemon management.

Qualified Claims

  • Voice latency depends on the provider (OpenAI Realtime ~200ms, Gemini Live ~300ms, local Whisper+Piper ~500ms). FERAL adds ~50ms of WebSocket relay overhead.
  • “Local-first” means the Brain runs locally, but cloud LLM providers are used by default. For fully local operation, configure Ollama.