Skip to content

Security: placet-io/facio

Security

SECURITY.md

Security Policy

facio runs autonomous LLM agents that can read files, execute shell commands and talk to external services. This document describes the threat model, the defense layers actually implemented in the codebase, and the hardening that is applied out of the box by the Docker quickstart.

Reporting a Vulnerability

Please report security issues privately:

  1. Do not open a public GitHub issue.
  2. Open a GitHub Security Advisory on the repository, or email the maintainers.
  3. Include reproduction steps, impact assessment and, if possible, a suggested fix.

Initial response target: 48 hours.


Threat Model

facio assumes the LLM and any content it processes (web pages, files, channel messages, tool outputs) are untrusted. The defense goal is therefore not to prevent the model from misbehaving, but to ensure that misbehavior cannot:

  • read or exfiltrate stored secrets,
  • escape the workspace directory,
  • escalate privileges on the host,
  • or produce side effects that survive a container restart.

Trusted boundary: the operator (the human running facio) and the configuration files they control. Everything past the LLM input is downstream and treated as adversarial.


Defense Layers

1. Container hardening (Docker quickstart)

The shipped docker-compose.yml, docker-compose.external.yml and docker-compose.dev.yml apply the same baseline to the facio service:

Control Value
user 1000:1000 (non-root)
cap_drop [ALL]
cap_add [SYS_ADMIN] — required by bwrap on rootful daemons
no-new-privileges enabled
seccomp custom profile seccomp-bwrap.json
apparmor unconfined (allows bwrap) — alternative profile recommended
pids_limit 512

The custom seccomp profile keeps clone/unshare/mount/pivot_root available so bwrap can build its namespaces, but blocks ~45 high-risk syscalls that no agent workload needs (bpf, perf_event_open, ptrace, kexec_*, *_module, userfaultfd, process_vm_readv/writev, mount_setattr, open_by_handle_at, swapon/swapoff, reboot, kcmp, personality, pkey_*, …). See quickstart/seccomp-bwrap.json.

Automatic privilege drop

SYS_ADMIN is only granted because rootful Docker daemons require it for unshare(CLONE_NEWUSER|CLONE_NEWNS). On hosts where unprivileged user namespaces are available, the capability is dropped automatically:

  • quickstart/setup.sh calls detect_unprivileged_userns() which returns true if
    • docker info reports rootless mode or a userns security option, or
    • the host kernel has kernel.unprivileged_userns_clone = 1, or
    • the host is macOS (Docker Desktop's LinuxKit VM ships unprivileged userns).
  • When detected, setup writes a generated docker-compose.override.yml that strips SYS_ADMIN and removes cap_add. The override carries a GENERATED-BY-SETUP-SH marker and is regenerated / removed on every run.

This means a fresh install on macOS or on a userns-remapped Docker daemon runs with cap_drop: [ALL] and no added capabilities, while bwrap still works inside the container.

2. Sandbox for shell commands (bwrap)

facio.agent.tools.sandbox.bwrap_command() wraps every exec/shell tool invocation in a bubblewrap namespace:

  • Workspace (~/.facio/workspace) → read-write.
  • Media directory → read-only.
  • /usr, /bin, /lib, /etc/{resolv.conf,ssl} → read-only.
  • The data directory ~/.facio/ (parent of workspace) → masked by tmpfs, so credentials.json, config.json, history.jsonl and friends are not even visible to subprocesses.
  • New PID, IPC, UTS and (where supported) user namespace.

Activation: tools.exec.sandbox: bwrap in config (the Docker image sets this by default). On bare-metal Linux without bubblewrap installed, it falls back to the unsandboxed path with a warning. Inside the official Docker image, bwrap is always available — including when running on macOS or Windows hosts via Docker Desktop, because the sandbox runs inside the container's Linux kernel.

3. Credential isolation

Secrets collected from humans (HITL ask_form password fields, OAuth tokens returned by skills) are routed through a dedicated CredentialStore:

  1. The plaintext value is written to ~/.facio/credentials.json (mode 0600, outside the workspace, masked by the bwrap tmpfs).
  2. The tool result handed back to the LLM contains only a ${credentials.KEY} placeholder.
  3. The value is registered with the global SecretRedactor. Any later appearance — in tool output, exception messages, log lines, session persistence — is rewritten to ***REDACTED*** before it reaches the model or disk.
  4. read_file and the exec deny-list refuse access to any path matching credentials.json.
  5. write_file / edit_file resolve ${credentials.KEY} to the real value at write time, so the agent can produce config files referencing secrets it never sees.
  6. Shell commands receive no credential env vars unless the operator opts in via tools.exec.exposed_credentials: ["GITHUB_TOKEN", …]. Manage at runtime with /credentials expose <KEY> and /credentials unexpose <KEY>.

The redactor is encoding-aware: it matches the raw value plus base64, hex and reversed encodings, and combines that with regex patterns for common API key shapes (sk-…, Bearer …, long hex tokens, GitHub/Google/Anthropic formats).

4. Tool guardrails

Three independent layers, all native — no external Python dependencies:

Trace policy (facio.security.guardrails.InvariantGuardrail). After each tool batch, the full message trace is inspected. If untrusted content (web_fetch, web_search, read_file results) matches one of the 12 built-in prompt-injection heuristics and is followed by a dangerous tool call (exec, write_file, edit_file, message), the turn is aborted with a guardrail_violation stop reason.

Input scanners (facio.security.llm_guard.LLMGuardScanner). Run on user messages before the model call. Defaults: PromptInjection, InvisibleText (zero-width and bidi override characters), Secrets. Threshold tunable via prompt_injection_threshold (default 0.7).

Output scanners. Run on tool results. Defaults: BanTopics, Secrets. Banned topics are persisted in memory/.guard_ban_topics.json and managed at runtime with /guard list|add <topic>|remove <topic>.

Configuration:

{
  "tools": {
    "guardrails": {
      "enabled": true,
      "input_scanners": ["PromptInjection", "InvisibleText", "Secrets"],
      "output_scanners": ["BanTopics", "Secrets"],
      "ban_topics": ["violence", "self-harm"],
      "prompt_injection_threshold": 0.7
    }
  }
}

5. File and exec policy

  • File tools enforce restrictToWorkspace automatically once the bwrap sandbox is active.
  • read_file blocks any filename of credentials.json regardless of path.
  • The exec safety guard blocks fork bombs, rm -rf /, mkfs.*, raw block device writes, cat/grep against credential files, and writes to facio's own state files (history.jsonl, .dream_cursor, …).
  • Default exec timeout 60 s, output truncated at 10 KB.

6. Channel access control

Each channel reads an allowFrom list. Empty allowFrom denies everyone. Use ["*"] to allow anonymous access explicitly.

{
  "channels": {
    "telegram": { "enabled": true, "token": "", "allowFrom": ["123456789"] },
    "whatsapp": { "enabled": true, "allowFrom": ["+4915112345678"] }
  }
}

The WhatsApp bridge binds to 127.0.0.1 only and supports a shared bridgeToken between the Python core and the Node.js worker. Auth state in ~/.facio/whatsapp-auth is created with mode 0700.

7. Logging and persistence

  • loguru is patched globally so every log record passes through SecretRedactor before formatting.
  • Memory artifacts (history.jsonl, MEMORY.md, SOUL.md, USER.md, session saves) are redacted on write.
  • Audit events (facio.utils.audit_log) record HITL submissions, credential changes and guardrail violations to ~/.facio/logs/audit.jsonl.
  • ~/.facio/config.json is forced to mode 0600 on every startup; if the parent directory has wider permissions it is tightened to 0700.

Operator Checklist

Before exposing facio to anything other than your own workstation:

  • allowFrom set on every enabled channel (no empty lists).
  • tools.guardrails.enabled: true in production configs.
  • tools.exec.sandbox: bwrap (default in the Docker image).
  • tools.exec.exposed_credentials is empty unless a CLI tool truly needs a secret, and it lists only that one key.
  • Container started via the shipped compose files (do not override cap_add, security_opt or pids_limit without auditing the new values).
  • On rootful Docker hosts, verify setup.sh did not create a docker-compose.override.yml — if it did, your kernel supports unprivileged userns and SYS_ADMIN is no longer needed.
  • LLM provider spending limits and rate limits configured upstream.
  • ~/.facio/ lives on a volume backed up out-of-band; secrets are plaintext at rest (mode 0600).
  • Custom skills and channel plugins reviewed — they run with the same privileges as core tools.

Known Limitations

  • Plain-text secrets at rest. credentials.json is 0600 JSON. OS keyring integration is not yet wired; high-security deployments should mount a ramdisk or encrypted volume at ~/.facio/.
  • No request-level rate limiting. Channels accept whatever the upstream service forwards; pair with a reverse proxy or channel-side limits.
  • No session expiry. Sessions persist until pruned manually.
  • AppArmor unconfined. The default container profile is unconfined because bwrap conflicts with the distro default. Sites with a custom AppArmor profile that permits unshare/mount should pin it explicitly.
  • bwrap protects subprocesses, not the agent itself. A successful exploit of the Python process can still touch ~/.facio/ directly. The container hardening (cap drop, seccomp, no-new-privs, pids limit) is the outer ring; bwrap is the inner ring around shell commands.

References

There aren't any published security advisories