> ## Documentation Index
> Fetch the complete documentation index at: https://celly.agub.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Security

> The enforced security invariants in Celly and an honest account of what the sandbox boundary does and does not protect.

Trust boundary: the sandbox is the boundary. The bot and the host are trusted.
The mounted project directory and everything inside it are untrusted.

Celly favors invariants that are enforced in code and asserted at runtime over
documentation that relies on good behavior.

## Boundary map

```mermaid theme={null}
flowchart LR
    subgraph external["External"]
        discord["Discord"]
        providers["Provider APIs"]
    end

    subgraph host["host · trusted"]
        bot["Celly bot<br/>Discord token in .env"]
        db[("SQLite<br/>projects · passwords")]
        cli["sbx CLI<br/>argv-only · shell:false"]
        loop["127.0.0.1:HOSTPORT<br/>Basic auth"]
    end

    subgraph sandbox["sbx microVM · the boundary · per project"]
        serve["opencode serve :4096"]
        env["~/.config/celly/opencode.env<br/>mode 0600"]
        policy["default-deny permission policy<br/>re-asserted after wake"]
        project["mounted project<br/>untrusted"]
    end

    discord <--> bot
    bot --> cli
    cli -- "sbx create / exec" --> serve
    bot -- "re-assert policy" --> policy
    loop -- "SDK" --> serve
    serve -- "SSE" --> bot
    db -. "server password" .-> loop
    serve --- env
    serve --- policy
    serve --- project
    serve -- "outbound request · credentials injected by the sbx proxy" --> providers
```

## argv-only spawns

Every `sbx` invocation uses `spawn(bin, args, { shell: false })`. No command is
ever passed through a host shell:

* Sandbox names are validated by regex.
* Project paths and `!cmd` are passed as single argv elements.
* Unit tests include adversarial names and paths.

Only `src/sbx.ts` spawns processes; a test guards that against regressions.

## Per-project microVM

Each project runs in its own `sbx` sandbox with only its project directory
mounted. The host filesystem outside that directory is unreachable by the agent.

## Path containment and denylist

* A project's directory must resolve (realpath, case-insensitive) **inside**
  `PROJECTS_ROOT`. Ancestors and descendants of denied paths are rejected too.
* The denylist covers the bot repo, `DATA_DIR`, sensitive profile subtrees
  (`.ssh`, `.aws`, `.gnupg`, `.config`, `.docker`, `.kube`, `.azure`, `.npmrc`,
  `.netrc`, `.celly`, `AppData`), and system directories.
* Text attachments are sanitized (basename only, no `..`, no reserved Windows
  device names, no control characters) and written under `.celly/inbox` with a
  containment check after resolution.
* A single helper owns path logic; call sites do not hand-roll it.

## Loopback and password

The generated sandbox config enables password auth
(`OPENCODE_SERVER_PASSWORD`), and the server is published only on loopback. The
per-project password is written to `~/.config/celly/opencode.env` (mode `0600`)
inside the sandbox and stored in the bot database. It rotates when a sandbox is
recreated.

## Admin and backup surfaces

* The admin page binds `127.0.0.1:ADMIN_PORT` only and has no authentication
  **by design**: it is safe precisely because it is not network-exposed. The
  consequence is that anything running locally on the host can read status and
  redacted log tails and start or stop projects, so local code shares the host
  trust boundary. It must never be rebound to `0.0.0.0`, published, or
  port-forwarded; use an SSH tunnel for remote access.
* It exposes project names/status, start/stop actions, and redacted log tails.
  Log redaction reuses the same `redact` path as `bot.log`.
* Backups are written under `DATA_DIR/backups` (`VACUUM INTO` output) and stay
  in the host trust domain, alongside `bot.db`.

## Bot-enforced permission policy

The sandbox config sets a default-deny policy, and the bot re-asserts it at the
API layer after every wake and health check so a project-level `opencode.json`
cannot weaken it:

```json theme={null}
{
  "$schema": "https://opencode.ai/config.json",
  "share": "disabled",
  "permission": {
    "*": "allow",
    "bash": {
      "*": "allow",
      "git push*": "deny",
      "git clean -fdx*": "deny",
      "npm publish*": "deny",
      "pnpm publish*": "deny",
      "yarn publish*": "deny",
      "printenv*": "deny",
      "env": "deny",
      "cat *opencode.env*": "deny",
      "cat */.config/celly/*": "deny"
    },
    "external_directory": "deny",
    "question": "allow"
  }
}
```

* The `bash` deny list also blocks common inspection utilities (`awk`, `base64`,
  `cat`, `cp`, `grep`, `head`, `less`, `od`, `sed`, `strings`, `tail`, `xxd`)
  from reading `opencode.env` or `~/.config/celly/`, plus catch-all patterns for
  both paths.
* `external_directory: deny` keeps tools inside the mounted project.
* `question: "allow"` lets the agent ask questions; Celly decides per approval
  mode. `auto` and `plan` reject them immediately, and `buttons` surfaces them
  to authorized members. There is no headless deadlock either way.
* Any permission request that still surfaces is answered from the policy — there
  is no catch-all auto-allow.

## Approval decisions

`buttons` mode turns mutating permission requests into Discord messages.
Decisions are bound to the pending request id and are only accepted while the
request is live; stale or post-restart clicks answer "this request is no longer
active". Read-only tools and every pattern already denied by the policy (bash
deny list, sensitive paths, unknown tools) are decided without asking.

Every permission decision, question answer or rejection, `!shell` command, and
`/mode` change is appended to `DATA_DIR/audit.jsonl` (mode 0600, one JSON object
per line) with timestamp, channel, thread, actor, kind, detail, and decision.
Audit appends are best-effort: a failure logs and never fails the interaction.
The log stores tool names, patterns, and shell commands verbatim and never
tokens or passwords.

## Worktrees

Thread worktrees live under `<project>/.celly/worktrees/<slug>`, inside the same
mounted project directory and the same per-project microVM as the project root.
They add no new host paths: the `PROJECTS_ROOT` containment and sensitivity
checks still apply, `.celly/` is appended to the project's `.gitignore` on first
use, and every git invocation runs in the sandbox through argv-only `sbx exec`.
`/worktree merge` refuses dirty worktrees and dirty project roots before it
touches the main branch.

## Secrets

* `DISCORD_TOKEN` lives only in `.env` (gitignored).
* Provider credentials live in `sbx secret` and are injected by the proxy, never
  stored in the bot or the repository.
* Managing `sbx secret` from Discord is deferred; credentials stay host-only.
* The per-project server password is never placed on a host command line, is
  never logged, and is masked in `/project status`.
* Log redaction covers tokens, passwords, and Authorization headers.

## Honest caveat

The deny list is **defense-in-depth, not a hard isolation boundary**. An agent
allowed to run bash can still run arbitrary *allowed* commands. The blast radius
is contained by the microVM:

* `OPENCODE_SERVER_PASSWORD` only guards a loopback-published port reachable
  from inside the sandbox and from the host's `127.0.0.1`.
* Provider credentials are injected by the `sbx` proxy rather than stored in the
  sandbox.
* A mounted project is untrusted; never keep secrets in it.

The sandbox is the real boundary. Treat the policy as reducing accidental
damage, not as preventing a determined agent from doing anything its allowed
commands can do.

## Related

* [Architecture](/reference/architecture) — where these controls live.
* [Configuration](/guides/configuration) — the path denylist and `DATA_DIR`.
