Please report suspected vulnerabilities privately to ua.lyo.su@gmail.com (or open a GitHub private security advisory). Do not file public issues for undisclosed vulnerabilities. We aim to acknowledge within 72 hours.
Untrusted code runs in per-session containers locked down by a tested builder
(sandbox-controller/sandbox-spec.js): never privileged, no-new-privileges,
all capabilities dropped (only CHOWN/SETUID/SETGID added back for the
boot sequence, plus NET_ADMIN/NET_RAW for the firewall setup when egress is
on), memory / CPU / PID limits, and no network until egress is explicitly enabled
(see "Network egress from sandboxes" below). The
container starts as root just long enough for its entrypoint to chown the
bind-mounted workspace, then drops to the unprivileged 1000:1000 user;
every command the agent runs is pinned to 1000:1000, so no untrusted code ever
runs as root. The controller never lets a caller specify container options — it
only requests this fixed, safe shape.
Each user gets their own per-user workspace and /shared directories, and a
container only ever bind-mounts the requesting user's own paths — so the shared
1000 uid never crosses the isolation boundary (separation is by container +
bind mount, not by uid).
The controller reaches the Docker daemon through a socket-proxy that exposes
the container and exec endpoints, the image endpoints (IMAGES=1) and read-only
GET /info (INFO=1, for the gVisor runtime probe). The controller only inspects
and pulls the sandbox image (to re-pull it after a prune), but the proxy cannot
narrow IMAGES=1 by method: with POST=1 it allows every /images endpoint,
including load, tag, push, delete and prune. Build, network, volume and swarm
endpoints are denied, and the raw host socket is never mounted into the
controller itself. The platform never touches the Docker socket directly.
The hardening above applies to both runtimes. What differs is the container↔host boundary itself, and that is the one choice worth making deliberately:
runc(default) — the container shares the host kernel. Every syscall the sandbox makes is served by that kernel, so a kernel bug reachable from a syscall is the escape path and there is no second wall behind it. Boots anywhere with no host setup, at native speed. The controller logs anisolation.unhardenedwarning at boot so the posture stays visible.runsc(gVisor, opt-in) — a user-space kernel intercepts those syscalls, so untrusted code talks to gVisor instead of to the host kernel; a kernel bug now has to be reachable through that layer first. No KVM required (unlike Kata/Firecracker), so it runs on ordinary VPS hosts. Enable it withsudo sh scripts/install-gvisor.shon the host plusSANDBOX_RUNTIME=runsc. The resulting "secure" profile is fail-closed: the controller refuses to boot ifrunscisn't registered on the daemon — it never silently falls back.
What gVisor costs. None of these is a reason to skip it for untrusted code, but all of them are real and worth knowing before you flip the switch:
| Cost | What it means in practice |
|---|---|
| Host setup, Linux only | Root install plus a Docker restart. The script also enables userns-remap, which shifts container uids on the host — a multi-tenant requirement, not an optional extra. On a host that already runs Capka, back up first (Backup & restore): Docker then keeps containers, images and volumes in a separate storage root, so the database seems to vanish until you restore it, and files under ./data keep owners the remapped containers may not be allowed to write. |
| Speed | Syscalls are serviced in user space, so syscall-heavy work (many small file operations, spawning processes) is slower; CPU-bound work much less so. |
| Resource budget | gVisor's own processes are charged to the sandbox's SANDBOX_PIDS_LIMIT and to the same memory cgroup as the workload. gVisor's stub processes are a high-water mark that is never reclaimed, so a long agent session spends the PID budget permanently and exhausting it kills the sandbox outright — hence the 1024 default; under ~128, image and document rendering dies with a misleading Cannot allocate memory. |
| Egress plumbing | Under gVisor the in-container firewall needs CAP_NET_RAW and the legacy iptables backend. install-gvisor.sh registers the runtime with --net-raw=true for exactly this reason; if you ever register runsc by hand, keep that flag or every sandbox dies at startup the moment egress is on. |
| Upgrades | Fail-closed cuts both ways: anything that de-registers runsc on the daemon stops the controller from booting rather than quietly downgrading it to runc. That is the intended behavior — treat it as a deploy-time check, not a surprise. |
Choosing. Stay on runc while you know who runs code — a single operator, or
colleagues you would already trust with a shell on that box; the sandbox is then
protecting you from mistakes, not from people. Move to runsc once that stops
being true: strangers, customers, or code an agent wrote that nobody reviews. For
untrusted or multi-tenant code, combine gVisor with rootless Docker (below) —
gVisor hardens the container↔host boundary, rootless decides what an escape is
worth, and neither substitutes for the other.
A compromised controller could still create a non-privileged sandbox. The only way to make a container escape not equal host root is to run the Docker daemon rootless.
# on the host, as a non-root user
# (see https://docs.docker.com/engine/security/rootless/)
dockerd-rootless-setuptool.sh install
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
# Point the socket-proxy bind at the rootless socket (the default is the rootful
# path, so this line is REQUIRED for rootless — otherwise sandboxes can't start):
echo "DOCKER_SOCKET=/run/user/$(id -u)/docker.sock" >> .env
sh scripts/up.sh # the stack now drives a rootless daemon; an escape lands unprivileged| Deployment | Required posture |
|---|---|
| Single-operator / trusted users | Default stack. Egress is off until an admin enables it in-app; set SANDBOX_ALLOW_NETWORK=false to hard-forbid it regardless of the UI. |
| Team, semi-trusted users | Above + rootless Docker, set PUBLIC_URL, TLS in front (see Caddy profile). |
| Multi-tenant / internet-facing | Above + rootless or gVisor (runsc) runtime, SANDBOX_ALLOW_NETWORK=false (or a vetted egress policy), external managed Postgres, regular backups. |
Egress is governed by two independent layers, and a sandbox reaches the internet only when both allow it:
SANDBOX_ALLOW_NETWORK— the deployment-level kill-switch on the controller. When unset the controller is fail-closed (no sandbox gets network, regardless of any in-app setting). The shippeddocker-compose.ymldefaults it totrueso an admin can turn egress on from the UI without editing.envand redeploying — it raises the ceiling, it does not by itself grant egress.sandbox_network— the org-wide default (Settings → Security → Network, "Let code reach the internet"), which isnoneout of the box. This is what makes a fresh deployment effectively no-outbound by default even though the kill-switch above ships open.
So the default posture is: no egress until an admin explicitly enables it in-app.
For untrusted / multi-tenant hosts, set SANDBOX_ALLOW_NETWORK=false to hard-forbid
egress at the controller so the in-app toggle can't open it at all. Enabling egress
widens the blast radius of any sandbox compromise. Whenever egress is on, the
in-container firewall additionally blocks private and cloud-metadata ranges and
refuses to start if its rules can't be verified.
SANDBOX_EGRESS_ALLOW (blank by default) narrows egress from "the public internet"
to a named set of hosts. It is what closes the exfiltration path that open egress
leaves: an agent that can read a token can also post it anywhere.
Set it, and a sandbox with network access is built differently — it joins the
capka-sandbox-egress network, which is internal, so Docker installs no route
off that bridge and no public DNS, and its own firewall permits exactly one
destination: the egress-proxy service. That proxy speaks HTTP CONNECT, resolves
each requested host itself, checks the resolved address, and tunnels only to
what the allowlist names.
Syntax: example.com (that host, port 443), *.example.com (its subdomains, not
the apex — list both if you want both), example.com:8443 (that port only). A
malformed entry is dropped, and a list that parses to nothing reaches nothing.
Enabling or disabling it rebuilds sandbox containers: the network a container joins is fixed when it is created, so the change reaches a running sandbox only by replacing it. Files survive; processes inside do not.
What this does not do, stated plainly because the gaps matter more than the guarantees:
- Plain
http://and non-HTTP protocols stop working. The proxy tunnels TLS and nothing else — no SOCKS, sogit+ssh, database clients and raw sockets fail closed. Tools that ignore proxy environment variables (notablyurllib3used directly) also fail; the JVM is handled viaJAVA_TOOL_OPTIONS. - An allowlisted host that fronts other origins allows them too. A CONNECT tunnel is opaque, so a request inside it can name a different virtual host on the same address. Nothing short of TLS interception (which would mean a CA in the container — deliberately not done) prevents this. Prefer narrow, single-purpose hostnames over a CDN apex.
- An allowlisted host that accepts uploads is still an exfiltration route.
Allowing
github.comallows a gist; allowing a package registry allows a publish. The allowlist limits where data can go, not whether it can. - It needs the current sandbox image. The firewall lives in the image's
entrypoint. The controller refreshes a registry-sourced image on every start, so
an upgrade normally picks up the new rules by itself — but a deployment building
from source that has not rebuilt, or one whose refresh pull failed (logged as a
warning; it keeps serving the image it has), still runs the previous release's
rules.
CAPKA_VERSIONpinned to an older tag is that older tag, deliberately. - One sandbox reaching another is blocked by the sandbox's own firewall, not by
the network. Every gated sandbox shares one
internalnetwork, andinternalisolates it from the outside world, not from its siblings — nor from the Docker host's own gateway, which is why the in-container default-deny is load-bearing rather than defence in depth. Because a rule present in the table is not necessarily a rule being applied (gVisor's netfilter is partial), the entrypoint probes its own DROP before dropping privileges and refuses to run if a connection it must not be able to make is answered. A per-session network would remove the shared bridge entirely; it is not implemented, because deleting one needs Docker API access the socket proxy deliberately withholds. - The proxy's address is pinned per sandbox. A firewall rule names an address,
resolved when that sandbox started. The proxy therefore holds a fixed address
(
SANDBOX_EGRESS_PROXY_IP); if you override it, or run two Capka stacks on one host — they share the un-prefixedcapka-sandbox-egressnetwork and thecapka-egress-proxyalias — expect the second stack's proxy to collide loudly rather than take over quietly.
Each workspace has a byte budget (MAX_WORKSPACE_MB, default 500). It is
enforced at command boundaries: the controller re-measures the tree and
refuses the next command with 413 WORKSPACE_FULL once it's over. A single
command (or a detached background job) can therefore transiently overshoot the
budget before the following command is blocked. A per-file hard cap
(MAX_FILE_MB, kernel-enforced via RLIMIT_FSIZE) stops the one-shot disk bomb
(fallocate -l 100G), but many-small-files growth within one command is only
caught on the next check.
This poll-based quota is adequate for single-operator / trusted-user
deployments. For multi-tenant or untrusted workloads, back DATA_ROOT with
a filesystem that enforces a real hard limit — an XFS/ext4 project quota or
a size-limited volume per deployment — so no single workspace can exhaust
the host disk regardless of command timing. The app-level quota then stays as
defense-in-depth on top.
The agent can work with folders that live outside its workspace: a directory
on the server bind-mounted into the sandbox at /folders/<name>. This exposes the
operator's own filesystem to sandboxed code, so it is gated deliberately:
- Off by default. Two independent org settings, both off by default:
host_folder_access(true/false) governs SERVER folder bind-mounts and is admin-only;pc_folder_access(off/admins/everyone) governs folders a user connects from their own computer (synced into/workspace, not a host mount). Nothing can be attached until an admin turns the relevant one on. - Admin-confirmed, every time. Attaching a server folder always shows a
confirmation card (it is not bypassable even in autonomous mode), and is recorded
in the audit log (
folder.add/folder.remove). - Read-only by default, mounted with
HostConfig.Mounts(notBinds, which would silently create a missing source as a root-owned dir) andPropagation: rprivate. - Validated by a denylist. The controller's
mount-safetyrejects system trees (/etc,/proc,/sys,/dev,/usr,/boot,/root, …) and anything that contains or sits under the data root (DATA_ROOT/HOST_DATA_ROOT) — mounting a child would leak other users' workspaces, an ancestor the whole store. All checks are directory-boundary checks (/datanever matches/data-archived). - Optional hard perimeter. Set
SANDBOX_MOUNT_ALLOW(:-separated roots) to restrict mounts to subpaths of those roots — recommended for multi-admin deployments. When unset, any path passing the denylist is allowed and the admin confirm is the final gate.
Honest limitation: the controller runs in a container and cannot resolve
host-level symlinks — a host symlink under an allowed root could point elsewhere
on the host. The denylist + SANDBOX_MOUNT_ALLOW + the admin confirm are the
mitigations; there is no server-side realpath of arbitrary host paths.
A read-write mount must be writable by uid 1000 on the host (the entrypoint does
not chown /folders/*); otherwise the agent sees EACCES. suid binaries in a
mount are neutralized by no-new-privileges + exec as uid 1000. gVisor is
recommended for deployments using host mounts — its default
--file-access-mounts=shared is correct for folders written by both host and
sandbox.
Installing a plugin pulls code from a third-party repo into the capability system. The trust model:
- Pinned to a commit. An install resolves its source ref to a concrete git commit and pulls the tree + files at that SHA — a consistent snapshot and a provenance record of exactly what was installed. A re-install re-pulls the same pinned commit; only an explicit upgrade moves the pin.
- Code execution is disabled by default. Every stdio MCP server a plugin
routes (bundled code or a bare
npx/uvxthat fetches a remote package) is installed off; an admin reviews and enables it in Extensions. Sandbox isolation is the containment; this is informed consent. - Upgrades are reviewable. An upgrade previews the file-level diff between the pinned and target commits (flagging changed connector definitions) and applies exactly the reviewed commit, so a hostile upstream can't swap in a different commit between review and apply.
This reduces — it does not eliminate — the risk of running third-party code. Treat marketplace sources as you would any dependency: install from repos you trust.
CAPKA_MASTER_KEYencrypts provider API keys at rest and lives outside the database, so a DB leak alone cannot decrypt them. 64 hex chars. In production the app is fail-closed: with noCAPKA_MASTER_KEYit still starts, but every sign-in and every use of a stored key fails (the boot log says so) rather than fall back to a DB-stored key. SetALLOW_DB_MASTER_KEY=trueto knowingly accept the insecure fallback (dev/testing). With a DB-stored key, every database dump — including each backup — also contains the key that decrypts it.CONTROLLER_SECRETgates the platform↔controller channel; the controller refuses to boot on the default value in production.scripts/up.shgenerates strong values into.env(mode600) on first run and never overwrites an operator-set value.
Users and admins can change settings, connectors and skills by asking the agent
(see src/lib/manage/). This is designed so the agent cannot change anything
sensitive on its own — important because the agent processes untrusted content
(scraped pages, uploaded files, MCP tool results) that could carry a prompt
injection.
- Role from the session, never the prompt. Every action is authorized from
the signed-in identity in
dispatch.ts; a non-admin cannot even see org settings (geton a hidden control returnsnot_found, no enumeration leak). - Confirmation is a real boundary, not the model's word. A risky change (any org setting, adding/removing a connector or skill) is only ever staged: the server stores the exact pending mutation (single-use, 10-minute TTL, bound to the user) and hands the model only an opaque id it cannot replay. The change applies only when the human acts on their own authenticated channel — the web Confirm button (session cookie) or a Telegram inline button (callback tied to the Telegram user). So a prompt-injected agent in an admin session can ask to disable the egress firewall, but cannot apply it; the staged change dies at its TTL unless the human clicks. Undo travels the same path.
- Secrets never transit chat. Connectors that need an API token are configured on the settings page, not dictated to the agent; the agent only wires up OAuth (a browser sign-in handoff) and non-secret config.
Telegram sign-in creates users with the predictable placeholder address
tg<telegram id>@telegram.local. v0.42.0 and earlier did not refuse such an address
on every sign-up path, so someone may have registered one before its owner signed in
with Telegram; later releases refuse it everywhere but leave an existing row alone.
If this install ever ran v0.42.0 or earlier, list the users on those addresses who
sign in, or are linked to the bot, as anyone but the Telegram id in their address
(read-only; run it in the install directory):
docker compose exec -T postgres psql -X -U Capka -d Capka <<'SQL'
SELECT u.id, u.name, u.email, u.role, u.status, u.created_at,
CASE WHEN EXISTS (SELECT 1 FROM account a WHERE a.user_id = u.id AND a.provider_id = 'telegram'
AND 'tg' || a.account_id || '@telegram.local' = lower(btrim(u.email))) THEN 'shared'
WHEN EXISTS (SELECT 1 FROM account a WHERE a.user_id = u.id AND a.provider_id <> 'telegram') THEN 'squat'
ELSE 'moved' END AS kind,
(SELECT string_agg(a.provider_id || ':' || a.account_id, ', ')
FROM account a WHERE a.user_id = u.id) AS sign_in,
(SELECT string_agg(l.telegram_user_id || coalesce(' @' || l.telegram_username, ''), ', ')
FROM telegram_links l WHERE l.user_id = u.id) AS bot_link
FROM "user" u
WHERE lower(btrim(u.email)) LIKE '%@telegram.local'
AND (EXISTS (SELECT 1 FROM account a WHERE a.user_id = u.id
AND NOT (a.provider_id = 'telegram'
AND 'tg' || a.account_id || '@telegram.local' = lower(btrim(u.email))))
OR EXISTS (SELECT 1 FROM telegram_links l WHERE l.user_id = u.id
AND 'tg' || l.telegram_user_id || '@telegram.local' <> lower(btrim(u.email))));
SQLNo rows is the expected result: a Telegram user's only sign-in is telegram:<n>, where
<n> is the number in their own tg<n>@telegram.local address. kind sorts the rows
that do appear:
squat: a sign-in other than Telegram (a password) and notelegram:<n>for its own<n>. Someone else registered the address. In Settings → People (/settings/users) suspend that user, which signs them out everywhere, then remove them.moved: onlytelegram:entries, or none, and none for its own<n>. Usually the owner, who disconnected Telegram on the Settings page and linked another Telegram account. A squat that linked its own Telegram and then removed its password looks the same. Removing the row deletes its chats, so do not remove it on this result alone: ask the person behind it (name, the Telegram account inbot_link) whether they first signed in with Telegram<n>.shared: the real Telegram user's account, with their chats, that someone else can also sign in to; removing it deletes their data. Suspend it, then run the statements below. They delete every other sign-in and bot link, every session and pending bot link code (a suspended account can still sign in, and that session works again once it is reactivated; releases after v0.42.0 sign it out and drop its codes on reactivation themselves, so those twoDELETEs matter only on older ones), switch off the account's automations and unshare its chats: a webhook URL or a share link the other person created keeps working once the account is active again. If the owner is left with no bot link, their next message to the bot restores it.
docker compose exec -T postgres psql -X -U Capka -d Capka <<'SQL'
DELETE FROM account WHERE user_id = '<id>' AND NOT (provider_id = 'telegram' AND account_id = '<n>');
DELETE FROM telegram_links WHERE user_id = '<id>' AND telegram_user_id <> <n>;
DELETE FROM link_codes WHERE user_id = '<id>';
DELETE FROM session WHERE user_id = '<id>';
UPDATE automations SET enabled = false WHERE user_id = '<id>';
UPDATE chats SET visibility = 'private', share_token = NULL WHERE user_id = '<id>' AND share_token IS NOT NULL;
SQLThen reactivate it. Before their next chat the owner checks Settings → Extensions (skills and connectors), Memory and Providers (when shown) and removes anything they did not add: a connector receives the agent's tool calls with their data. They turn back on the automations they want, using "Replace the address" on a webhook automation, and re-share the chats they meant to share (each gets a new link).
We'd rather state these plainly than imply a stronger posture than ships today. Capka is a self-hosted Docker app for solo operators and small/medium teams, with sandboxed execution and a documented hardening path for higher-trust deployments — not a turnkey-certified multi-tenant platform.
- Long-running process required. The agent worker runs in-process inside
the platform container via Next.js instrumentation. Capka must run as a
long-lived process (Docker/VM); serverless/edge hosts that freeze between
requests are unsupported. A separate
workerservice running the same code is a planned option, not a current one. - Realtime/queue scale boundary. The task queue and realtime bus are Postgres
(
SKIP LOCKEDleases +LISTEN/NOTIFY). This is deliberately dependency-light and right for small/medium load; it is not an event-streaming layer (8 KB NOTIFY payload limit, single-DB fan-out). Very high concurrency needs a different bus. - CSP is partial. The shipped policy is the inline-safe slice (
object-src,base-uri,form-action,frame-ancestors). A strictscript-srcwithoutunsafe-inline(per-request nonce) is not yet enabled, so this is not full XSS containment for model/user-generated content. - SSRF is narrowed and connection-pinned. Outbound fetches to user-supplied URLs (MCP servers, OAuth discovery, marketplace, custom provider base URLs, and provider model listing) always block link-local/cloud-metadata ranges, strip credentials on cross-host redirects, and pin the TCP connection to the vetted IP so DNS can't rebind between the address check and the connect. Loopback and private ranges are reachable by default, so a provider on your own network (e.g. Ollama) works; an admin blocks them with Settings → Security → Network → "Block internal addresses for providers". Turn that on when people you don't fully trust can add connectors or providers. First-party fixed hosts (e.g. api.anthropic.com) use the default fetch — not a user-controlled SSRF vector. This is app-level defense-in-depth, not a substitute for network-level egress controls on an instance where reaching an internal service would be catastrophic.
- Auth depends on
better-auth. A young dependency carries its own advisory surface; keep it updated. Enterprise SSO/OIDC/SCIM is a separate commercial edition, not in this repo. - Docker socket is root-equivalent. Even via the socket-proxy, only rootless Docker (or gVisor) makes a container escape not equal host root — see above.
- Audit log is best-effort. Entries are written after the action, not in the same transaction, and a write failure is logged but does not block the action. In a DB outage a critical event could go unrecorded. Treat the audit log as strong evidence, not a hard guarantee; for compliance, ship logs off-box.
- Dependency audit has accepted residual advisories. Fixable ones are pinned
via
overrides(postcss, dompurify, andjs-yamlundergray-matter, whose input is also bounded: SKILL.md frontmatter must be plain YAML, without anchors or aliases, and at most 8 KB). The unused/_next/imageoptimizer is switched off (images.unoptimized). Whatnpm audit --omit=devstill lists is dev tooling pulled into the prod tree bybetter-auth's optional peer declarations — theesbuilddev-server advisory underdrizzle-kit(only affectsesbuild serve) andvitest(only while tests run); neither ships in the standalone runner image. The sandbox controller's one hit isuuidunderdockerode, used only by BuildKit session code the controller never calls. Their npm-offered fixes are major-version changes (or, forvitest, a patch npm 10 currently fails to resolve), so they're accepted, not applied. Re-evaluate on each dependency bump.
If your threat model exceeds these boundaries, run rootless + gVisor, front the app with your own WAF/egress controls, and budget for a security review before exposing it to untrusted users.