# 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`](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](https://github.com/containers/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:

```json
{
  "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`, `.reflection_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.

```json
{
  "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

- <https://github.com/placet-io/facio/security/advisories>
- <https://github.com/placet-io/facio/releases>
- See [`LICENSE`](LICENSE) for licensing terms.
