karucode is a coding agent, not a sandbox. It confines its own file tools to a workspace. It does not confine the programs you ask it to run, and it does not pretend to. This page is a spec: read it before you bind the server to anything other than loopback, and before you leave a run unattended.
What is contained
Every file tool resolves its path through a confined workspace before it touches anything:
- No
..escapes and no absolute paths outside the workspace root. - Symlink escapes are canonicalised away, so a link cannot be used to step outside.
- Writes are atomic: a file is replaced, never left half-written.
-
The system temp dir is a second root by default. Turn that off with
KARUCODE_ALLOW_TEMP_DIR=falseif you want the workspace to be the only place the file tools can reach.
New project roots are unrestricted by default. KARUCODE_PROJECT_ROOTS is a
colon-separated allowlist if you want to fix that too.
What is not
The shell is not sandboxed
A shell command runs as the server user, with that user’s permissions, its files, its network reach and its environment. Approvals are the gate, not isolation. Approving a command is the decision to let it run as you.
If the workspace is untrusted, or if you run unattended auto mode, run karucode
inside a container or a virtual machine. That boundary is yours to provide; the binary does not
add one.
Three more limits worth stating plainly:
- Plugins and MCP servers are trusted code. They are separate processes with no
filesystem sandbox. A rebuilt plugin is reloaded —
POST /api/plugins/reloadorplugins_watch— not restarted with the server. - Subagents share the parent’s live workspace, including dirty and untracked
files.
owned_pathsis advisory conflict reporting, not a lock: two agents can still write the same file. - There is no TLS in the binary. For remote access, terminate TLS in a reverse proxy in front of karucode.
Reaching it from another machine
By default the server binds to 127.0.0.1. A non-loopback bind is refused unless
KARUCODE_AUTH_TOKEN is set to at least 16 characters, or [access] is
enabled and backed by a real password or passkey — the bind, not the request, is what needs
the credential.
KARUCODE_ALLOWED_HOSTS extends the Host allowlist. An entry can be an
exact host, a host:port, or a *.domain wildcard — and the wildcard does
not match the bare domain, so *.example.com does not admit
example.com.
| Bind | Token | What is served |
|---|---|---|
| loopback | unset |
Only the Hosts 127.0.0.1, localhost and [::1], plus
anything in KARUCODE_ALLOWED_HOSTS or [access] public_url.
|
| loopback | set |
The Host allowlist check is skipped, but the Origin == Host rule stays.
|
| non-loopback | unset | The bind is refused, unless sign-in is enabled with a password or passkey. |
| non-loopback | set | Serves with the bearer token, and still without TLS. |
The consequence is worth knowing: with a token configured, the Host check is skipped, so the credential is what stands between a hostile page and your server — not the name it used to reach it.
Signing in
A bearer token is awkward in a browser: a page cannot put a header on the document that runs the
app. So the web UI has its own sign-in — a password or a
passkey — exchanged for an HttpOnly session cookie. Both work
alongside the token; neither replaces it.
- Password: hashed with Argon2id, verified in constant time. Only the hash is
stored, in the SQLite settings table — never in the config file, never in
GET /api/config. - Sessions: 32 random bytes in the cookie; the server stores only the SHA-256,
so a copy of the database cannot be turned into a login.
SameSite=Lax,HttpOnly, andSecurewhen the request arrived over TLS. Signing out deletes the row. - Lockout: eight wrong attempts from one address within 15 minutes lock that address out for five minutes — including the correct password.
- Passkeys: ES256 only (RSA and Ed25519 are refused at registration). The challenge, origin, relying-party id, user-present flag and signature counter are all checked; the attestation statement is not, so Touch ID, Windows Hello, a phone and a YubiKey all work.
- Behind a proxy: only the direct peer is compared against
trusted_proxies, so a client cannot claim to be loopback by sendingX-Forwarded-For.tunnel = "cloudflare"trusts Cloudflare’s published ranges.
The first-run wizard sets this up in one step: model endpoint, approval mode, and the
password or passkey. A passkey is bound to the host it was registered on, so set
public_url before registering the one you plan to use remotely.
In the browser
- CSRF: browser clients are protected by a custom header plus the
Origin/Hostcheck — and, once sign-in is on, by aSameSite=Laxsession cookie. A cross-site form post can set none of them. - DNS rebinding: with a loopback bind and no token, only
127.0.0.1,localhostand[::1]Hosts are served (plus your explicit allowlist and[access] public_url), so a hostile domain that resolves to your machine is still rejected by name. The guard is not skipped when sign-in is enabled: a cookie, unlike a bearer header, is sent automatically. - Secrets: never returned to the browser and never written to logs. The provider API key is write-only: you can set it, you cannot read it back.
- Changing the dangerous things: approvals and the provider endpoint require
KARUCODE_ADMIN_TOKEN— a second, separate secret from the auth token.
What the model cannot take with it
Child processes get a credential-free environment allowlist. A prompt-injected curl
that dumps the environment gets neither the harness token nor the provider key.
That is a statement about the environment, not a network policy: an approved command can still reach the network with whatever access the server user has, and file tools can still read the workspace. It closes the cheapest exfiltration path, not all of them.
Rendered content
Model output is not trusted markup. Markdown is rendered with raw HTML disabled and then sanitised, so a document in the transcript cannot inject script into the page.
A chat artifact is stronger than that: it is a separate document in a sandboxed opaque-origin frame, addressed by a random per-artifact capability — never the master token. Script network access, remote subresources and forms are blocked inside it.
Before you expose it
-
Put a credential in front of it:
KARUCODE_AUTH_TOKENof at least 16 characters, or a password/passkey with[access] enabled. Without one, a non-loopback bind is refused. -
Set
KARUCODE_ADMIN_TOKEN, so changing approvals or the endpoint needs a second secret. - Put a reverse proxy in front of it. The binary terminates no TLS.
-
Run it in a container or a VM if the workspace is untrusted, or if you use
automode. -
Back up the SQLite database in
KARUCODE_DATA_DIR. Every transcript, run and agent record is in it. -
Set
KARUCODE_LOCK_SETTINGS=1if you want settings pinned by the environment, with browser edits rejected403.
Found something that contradicts this page? The source of truth is
docs/SECURITY.md in the repository, and issues are public.