Security

Security

The trust boundaries, and the one that is missing.

Edit on GitHub (opens in a new tab)

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=false if 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/reload or plugins_watch — not restarted with the server.
  • Subagents share the parent’s live workspace, including dirty and untracked files. owned_paths is 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, and Secure when 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 sending X-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/Host check — and, once sign-in is on, by a SameSite=Lax session 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, localhost and [::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

  1. Put a credential in front of it: KARUCODE_AUTH_TOKEN of at least 16 characters, or a password/passkey with [access] enabled. Without one, a non-loopback bind is refused.
  2. Set KARUCODE_ADMIN_TOKEN, so changing approvals or the endpoint needs a second secret.
  3. Put a reverse proxy in front of it. The binary terminates no TLS.
  4. Run it in a container or a VM if the workspace is untrusted, or if you use auto mode.
  5. Back up the SQLite database in KARUCODE_DATA_DIR. Every transcript, run and agent record is in it.
  6. Set KARUCODE_LOCK_SETTINGS=1 if you want settings pinned by the environment, with browser edits rejected 403.

Read the full security notes

Found something that contradicts this page? The source of truth is docs/SECURITY.md in the repository, and issues are public.