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.
Please report security issues privately:
- Do not open a public GitHub issue.
- Open a GitHub Security Advisory on the repository, or email the maintainers.
- Include reproduction steps, impact assessment and, if possible, a suggested fix.
Initial response target: 48 hours.
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.
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.
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.shcallsdetect_unprivileged_userns()which returns true ifdocker inforeports rootless mode or ausernssecurity 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.ymlthat stripsSYS_ADMINand removescap_add. The override carries aGENERATED-BY-SETUP-SHmarker 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.
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, socredentials.json,config.json,history.jsonland 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.
Secrets collected from humans (HITL ask_form password fields, OAuth tokens
returned by skills) are routed through a dedicated CredentialStore:
- The plaintext value is written to
~/.facio/credentials.json(mode0600, outside the workspace, masked by the bwrap tmpfs). - The tool result handed back to the LLM contains only a
${credentials.KEY}placeholder. - 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. read_fileand the exec deny-list refuse access to any path matchingcredentials.json.write_file/edit_fileresolve${credentials.KEY}to the real value at write time, so the agent can produce config files referencing secrets it never sees.- 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).
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
}
}
}- File tools enforce
restrictToWorkspaceautomatically once the bwrap sandbox is active. read_fileblocks any filename ofcredentials.jsonregardless of path.- The exec safety guard blocks fork bombs,
rm -rf /,mkfs.*, raw block device writes,cat/grepagainst credential files, and writes to facio's own state files (history.jsonl,.dream_cursor, …). - Default exec timeout 60 s, output truncated at 10 KB.
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.
loguruis patched globally so every log record passes throughSecretRedactorbefore 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.jsonis forced to mode0600on every startup; if the parent directory has wider permissions it is tightened to0700.
Before exposing facio to anything other than your own workstation:
-
allowFromset on every enabled channel (no empty lists). -
tools.guardrails.enabled: truein production configs. -
tools.exec.sandbox: bwrap(default in the Docker image). -
tools.exec.exposed_credentialsis 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_optorpids_limitwithout auditing the new values). - On rootful Docker hosts, verify
setup.shdid not create adocker-compose.override.yml— if it did, your kernel supports unprivileged userns andSYS_ADMINis 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 (mode0600). - Custom skills and channel plugins reviewed — they run with the same privileges as core tools.
- Plain-text secrets at rest.
credentials.jsonis0600JSON. 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
unconfinedbecause bwrap conflicts with the distro default. Sites with a custom AppArmor profile that permitsunshare/mountshould 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.
- https://github.com/placet-io/facio/security/advisories
- https://github.com/placet-io/facio/releases
- See
LICENSEfor licensing terms.