Boundary map
argv-only spawns
Everysbx 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
!cmdare passed as single argv elements. - Unit tests include adversarial names and paths.
src/sbx.ts spawns processes; a test guards that against regressions.
Per-project microVM
Each project runs in its ownsbx 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/inboxwith 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_PORTonly 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
redactpath asbot.log. - Backups are written under
DATA_DIR/backups(VACUUM INTOoutput) and stay in the host trust domain, alongsidebot.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-levelopencode.json
cannot weaken it:
- The
bashdeny list also blocks common inspection utilities (awk,base64,cat,cp,grep,head,less,od,sed,strings,tail,xxd) from readingopencode.envor~/.config/celly/, plus catch-all patterns for both paths. external_directory: denykeeps tools inside the mounted project.question: "allow"lets the agent ask questions; Celly decides per approval mode.autoandplanreject them immediately, andbuttonssurfaces 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_TOKENlives only in.env(gitignored).- Provider credentials live in
sbx secretand are injected by the proxy, never stored in the bot or the repository. - OAuth credentials from
/login//login-codeare 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 secretfrom 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_PASSWORDonly guards a loopback-published port reachable from inside the sandbox and from the host’s127.0.0.1.- Provider credentials are injected by the
sbxproxy rather than stored in the sandbox. - A mounted project is untrusted; never keep secrets in it.
Related
- Architecture — where these controls live.
- Configuration — the path denylist and
DATA_DIR.