Skip to main content
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

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. It must never be exposed through a tunnel or port-forward.
  • 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:
  • 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.
  • OAuth credentials from /login / /login-code are sent only to the sandbox’s loopback OpenCode server; the bot never logs, audits, or echoes the authorization code or the returned tokens.
  • Managing sbx secret from Discord is deferred; credentials for non-OAuth providers 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.