Security Architecture
This page describes the security architecture of Codumentor, with a focus on the process model: what runs inside the server process, what runs in separate operating-system processes, and how the processes that handle untrusted content are sandboxed.
It is a design reference for security reviews and deployment approval. For the hands-on hardening checklist (authentication, TLS, secrets handling, file permissions), see Security; for host prerequisites and per-plugin setup, see Plugins in Production.
Design summary
Codumentor runs as a single server process. Individual agents, conversations, and users are not separate OS processes — they are concurrent tasks inside the server, separated by authentication and per-user data ownership.
Process-level isolation is instead applied where it provides a real security boundary: around the execution and parsing of untrusted content. Every action where model-generated or externally-sourced content could execute code — shell commands written by the LLM, external coding-agent CLIs, parsing of user-supplied office documents, rendering of fetched web pages — runs in a separate, sandboxed child process with a restricted filesystem view, no network by default, and a scrubbed environment.
The rationale: inside the server process, only Codumentor's own code and operator-approved plugins execute. Model output is data there — it is stored, streamed, and displayed, but never evaluated. The moment model output becomes code (a shell command, a build step, a document parser fed a hostile file), it crosses into a sandboxed child process. Drawing the boundary around execution rather than around agent instances means the boundary is a kernel-enforced one (namespaces and mounts), not merely a memory-separation one.
A second principle is fail-closed behavior: when sandboxing is enabled but cannot be provided (bubblewrap missing, sandbox setup error), the affected tools are disabled and the agent is told why. The product never silently falls back to executing agent-controlled work directly on the host.
The process model
One server process
The server is a FastAPI application hosted by uvicorn — a single asyncio-based OS process started with python -m codumentor ... api (or the frozen binary equivalent). There is no separate daemon: the HTTP API, SSE streaming, the agent engine, the plugin system, the background job queue, and the scheduler all live inside this one process. If the process stops, everything stops; there are no orphaned services to account for.
What runs in-process:
| Component | Role |
|---|---|
| API layer | FastAPI routes, auth middleware, SSE streaming |
| Agent engine | Orchestrates LLM calls and tool invocations per turn |
| Non-executing tools | File read/write, knowledge search, and similar tools that perform mediated I/O (see below) |
| Plugins | Operator-enabled extensions; run with full process access (trusted code) |
| Job queue & scheduler | Background jobs (ingestion, memory storage, scheduled runs) as asyncio workers claiming from a local SQLite queue |
| Data stores | SQLite databases (WAL mode) and the ChromaDB vector store, both on local disk |
Agents are tasks, not processes
Each conversation turn runs as an asyncio task inside the server. A single shared agent engine serves all users; the user and session identity are parameters of each turn, not properties of a process.
This is safe because of what does — and does not — happen in-process:
- No untrusted code executes in the server process. The LLM's output is text. Tool calls are dispatched by Codumentor's own tool runtime; the only tools that turn model output into executable code hand it to a sandboxed child process (next section).
- Multi-user separation is enforced at the authentication and data layer. Every request is authenticated (JWT); every conversation row is owned by a
user_id, and every conversation-scoped route verifies ownership before serving data (a foreign conversation ID returns 404, so IDs cannot be enumerated). Per-user secrets are stored encrypted per user and are resolved server-side without entering the model context by default. - File tools are mediated, not raw. The built-in file read/write tools do not give the model direct host filesystem access. Under workspace isolation they perform path-validated, overlay-aware I/O confined to the conversation's workspace; path traversal out of the workspace is rejected by the server before any I/O happens.
If your review question is "do agents run in separate processes?": no, and by design. Separate processes per agent would isolate trusted code from trusted code, while the actual risk — model-authored commands and hostile file content — would still need a sandbox. Codumentor spends the process boundary where the trust boundary is.
Where child processes appear
Trust model
| Trusted (runs with server privileges) | Untrusted (confined to a sandbox) |
|---|---|
| Codumentor's own code | LLM output, whenever it is executed (shell commands, build steps, code run by external agents) |
Operator configuration (codumentor.yaml) | Repository content, when executed (project setup scripts, builds, tests) |
| Enabled plugins (in-process, unsandboxed — enable only reviewed plugins) | User-uploaded office documents (parsed only inside a sandboxed worker) |
| Operator-configured MCP server commands (you choose which binary runs) | The MCP server process — it acts on model-authored tool arguments, so it runs in a sandbox of its own (see MCP servers) |
Fetched web content and model-generated diagrams (rendered in headless Chrome — sandboxed only if that binary has an AppArmor userns grant; see Browser rendering) | |
| Configured repo URLs, DB connections, LLM endpoints |
Two layers apply to agent-initiated execution, and they are independent:
- Approval — by default (
shell_always_allow: false) each shell command requires explicit user approval before it runs. Approval gating is enforced in the tool runtime, before any process is spawned. - Sandboxing — once approved, the command still executes inside the sandbox described below. Approval limits what runs; the sandbox limits what it can reach.
The execution sandbox
Sandboxing is provided by the workspace_isolation plugin using bubblewrap (bwrap), the unprivileged container tool also used by Flatpak. It requires Linux and a bwrap build with overlay support — see Plugins in Production → Workspace isolation prerequisites.
When the plugin is active, one policy covers the whole product. The following all run inside the same sandbox construction:
- Agent shell commands — every command the model runs via the shell tool, including detached/background processes.
- External coding agents (
external_agentplugin, sandbox tier 1) — full third-party CLIs such as Claude Code or Codex. The tier defaults to auto: whenworkspace_isolationis active, external agents are automatically sandboxed at tier 1; running them unsandboxed while isolation is active requires an explicit config override and logs a warning. - Office document workers — user-uploaded
.xlsx/.docx/.pptxfiles are parsed in a separate worker process wrapped in the same sandbox, so a malicious document exploiting a parser bug lands inside the sandbox, not the server. - Project setup scripts — repository-provided
setup.shbuild/dependency steps. - Isolated subagents — when
subagent_isolation: isolatedis configured.
Sandbox properties
Each conversation gets its own sandbox workspace. Every sandboxed process launch has:
| Dimension | Property |
|---|---|
| Filesystem — system | /usr, /bin, /lib, /lib64, /etc bind-mounted read-only. /var, /opt, /root, /srv and the host's real /home are not mounted and do not exist inside the sandbox. |
| Filesystem — repositories | Mounted at /workspace as a kernel overlayfs: the real repositories are the read-only lower layer; all writes go to a per-conversation copy-on-write upper layer on the host. The agent can freely modify "its" repo without touching the real checkout. |
| Filesystem — home & tmp | A synthetic per-session HOME and /tmp (persistent per session by default, configurable to per-invocation tmpfs). The host home directory is never visible. |
| Network | No network by default (--unshare-net, own empty network namespace). See "Network policy" below. |
| Processes | Own PID namespace (--unshare-pid) — sandboxed code cannot see or signal host processes. Own IPC namespace. --die-with-parent ensures no sandbox outlives the server. |
| System calls | A seccomp-BPF syscall filter (--seccomp) blocks the kernel-escape primitives that namespaces alone don't — nested user namespaces (clone/unshare with CLONE_NEWUSER, clone3), mount/pivot_root, ptrace, bpf, perf_event_open, the kernel keyrings, userfaultfd, open_by_handle_at, kexec, io_uring, and the TIOCSTI/TIOCLINUX terminal-injection ioctls. Configurable via sandbox.seccomp (default/log/enforce/off); ships audit-only by default (see "System-call filtering" below). |
| Environment | Cleared and rebuilt from a minimal baseline (PATH, HOME, USER, LANG, TERM plus non-interactive git/ssh settings). Server secrets, API keys, and host environment never leak in. Additional variables must be allow-listed by name (sandbox.inherit_env_vars). |
| File descriptors | All inherited descriptors are marked close-on-exec before each spawn, so sandboxed processes cannot inherit server sockets or database handles. |
| Time limits | Shell commands are bounded by the configured shell_timeout (hard cap 15 minutes); on expiry the whole process group is killed. External agents (tier 1) additionally get a memory cap (default 2 GiB via user-level systemd-run) and a wall-clock budget. |
One property to note for your platform-security review: bubblewrap runs unprivileged, so the process inside the sandbox has the same UID as the Codumentor service user — there is no user-ID separation between server and sandbox. The isolation is namespace- and mount-based: the sandboxed process simply cannot see the server's data directories, host credentials, network, or other processes. This is why we recommend running Codumentor under a dedicated, minimally-privileged service account (see Security): the service user's own privileges are the outer bound of what a sandbox escape could reach.
MCP servers: a long-lived sandbox with different defaults
A local (stdio) MCP server is not per-conversation — one process is started per configured server and shared by every conversation — so it gets its own sandbox rather than borrowing a conversation's. It is confined by default whenever workspace_isolation is enabled, and the profile is deliberately tuned for what these servers are:
| Dimension | MCP server | Agent shell (for comparison) |
|---|---|---|
| Network | Shared by default — most MCP servers exist to call a remote API. Set network: false per server to isolate. | No network |
| Repositories | /workspace, read-only (configurable to read-write or hidden) | /workspace, copy-on-write overlay |
HOME | A private persistent directory per server under ~/.codumentor/mcp-homes/ | Per-session, discarded on TTL |
| Processes, syscalls, environment | Same as the table above: own PID/IPC namespace, --die-with-parent, cleared environment, the configured seccomp filter | — |
The exposures this closes are the host credentials in the real $HOME (~/.ssh, ~/.gnupg) and the ability to signal the Codumentor process — neither of which the network namespace was protecting. Per-server configuration, including the mode: off exception for a server that cannot be confined, is documented in the MCP plugin reference.
Overlay writes and their lifetime
Sandbox writes are not silently discarded: the copy-on-write upper layer persists on the host (under ~/.codumentor/workspaces by default) so a conversation can be resumed and its changes reviewed, diffed, or pushed through the controlled push gateway. Upper layers are garbage-collected on a TTL (default 30 days from last use, configurable) and are readable only by the service user. Include this directory in your data-protection and retention planning alongside data/ — it can contain agent-modified copies of repository files.
Network policy
The default is no network inside the sandbox. Consequences and options:
- Model-authored shell commands cannot exfiltrate data or fetch remote code. Anything requiring network (dependency installation,
git push) either happens through project-setup caching or through the dedicated host-side gateways described below. - Operators can opt a deployment into shared networking (
sandbox.unshare_net: false) — this is a deliberate trade-off and not recommended where the agent processes sensitive repositories. - The read-only host inspection mode (
mode: host_readonly, used by the investigation flavor) defaults to asking the user once per conversation whether to share the host network; if no answer can be obtained, it fails safe to isolated. - For external agents (tier 1), an empty egress allow-list means full network isolation. Configuring a non-empty allow-list currently shares the host network namespace with the external agent and should be treated as "network enabled" for review purposes — per-destination enforcement is on the roadmap, not yet a kernel-level guarantee.
System-call filtering
Namespaces limit what sandboxed code can see; a seccomp-BPF filter limits which kernel entry points it can call — the layer that addresses the kernel local-privilege-escalation escape class (nested user namespaces, io_uring, bpf(), keyctl, userfaultfd, …) that namespaces and same-UID separation don't. bwrap applies the filter after its own setup, so it constrains the sandboxed payload and its descendants but never bwrap itself. The filter is a blocklist (not an allowlist), so ordinary build/test toolchains are unaffected — only the specific escape primitives are refused.
The filter is shipped as a pre-generated BPF blob per CPU architecture (so the product carries no libseccomp runtime dependency), and covers the compat ABIs (x32/i386 on x86-64) to close the multilib bypass. sandbox.seccomp controls it:
default(and unset) — audit-only log mode: blocked syscalls are logged by the kernel (SCMP_ACT_LOG) but still succeed. This is the rollout default: it surfaces any toolchain that trips a rule in your logs without breaking it.enforce— blocked syscalls are refused (EPERM;clone3returnsENOSYSso libc falls back to the filteredclone). Turn this on once your logs show no legitimate breakage.off— no filter (the escape hatch for workloads that genuinely need, e.g., nested user namespaces).
Loading is fail-closed: if a filter is requested but its blob can't be loaded (unsupported architecture, broken build), the sandbox is disabled (tools return a clear error) rather than run unfiltered.
Fail-closed guarantees
The sandbox is designed so that degradation disables capability rather than weakening isolation:
- Isolation enabled but bubblewrap unavailable (wrong platform, missing binary, no overlay support): shell and file tools, office document workers, tier-1 external agents, and local MCP servers all refuse to run, returning a clear error. Nothing falls back to host-direct execution.
- Sandbox setup fails mid-flight (snapshot, project-setup, or branch-preparation error): the conversation continues with execution tools disabled — the error path installs a blocking strategy rather than leaving the host-direct default in place.
- External agents under isolation: the automatic tier resolution requires an explicit, logged config override to run an external agent unsandboxed while isolation is active.
- Seccomp blob missing: a requested syscall filter that can't be loaded disables the sandbox rather than running it unfiltered (set
sandbox.seccomp: offto intentionally run without a filter).
If workspace_isolation is not enabled at all, shell commands execute directly on the host as the service user, gated only by the approval flow. For any multi-user or production deployment — certainly for a regulated environment — enable workspace_isolation; the Self-Hosting Walkthrough treats it as part of the standard install.
Browser rendering
The fetch_urls and diagram-rendering features drive a headless Chrome/Chromium instance — a separate OS process rendering untrusted web content. This process runs host-direct, outside the workspace_isolation bwrap, so Chrome's own multi-process sandbox is the only containment layer it has.
Codumentor always attempts to launch with that sandbox enabled, and gives it up only after an observed launch failure — at which point it logs a warning naming the likely cause and retries once with --no-sandbox. The verdict is remembered for the process, so the failed attempt costs a fraction of a second, once. CODUMENTOR_CHROME_SANDBOX=1 forces the sandbox and turns a failure into a hard error instead of a fallback; =0 skips the attempt entirely.
Know which browser you are running. On Ubuntu 24.04+ the
apparmor_restrict_unprivileged_userns=1default means Chrome's sandbox only works for a binary whose AppArmor profile grantsuserns. Theapparmorpackage ships such profiles for the system packages (/etc/apparmor.d/chrome→/opt/google/chrome/chrome,/etc/apparmor.d/chromium→/usr/lib/@{chromium}/@{chromium}), so a distro Chrome/Chromium is sandboxed out of the box. A Playwright-managed Chromium — which is what themermaid_render/webretrieveinstall instructions produce, and whichfind_chrome()prefers — lives under~/.cache/ms-playwright/and matches no shipped profile, so its sandbox cannot start and Codumentor falls back to--no-sandbox. See Browser sandbox on Ubuntu 24.04+ for the two ways to fix that.Check which you are getting:
grep "Chrome sandbox" <instance>/logs/codumentor.log— aretrying with --no-sandboxwarning means agent-controlled content is rendering unsandboxed.
Host-side subprocesses (by design)
A small set of helpers runs outside the sandbox, because their job is precisely what the sandbox removes: host network access, real-repository writes, or operator credentials. None of them execute agent-controlled code — they transport agent-produced content over a fixed command line. All of them use list-form argv (no shell interpretation, so no shell injection surface):
| Helper | Purpose | Hardening |
|---|---|---|
Push gateway (scm_push) | Pushes agent commits from the overlay to the real remote | Git hooks disabled (core.hooksPath=/dev/null), credential helpers disabled, file:// protocol disabled, scrubbed environment, credentials passed via 0600 files (never argv), timeout, output redaction |
| Memory storage | Publishes and commits the agent's knowledge-base entries to the real KB repo, then pushes | The memory curator itself runs sandboxed, in a disposable copy of the KB repo; the host then copies out only files under the KB's own directories (everything else is discarded and logged) and commits them in-process. No model-issued command touches the real repo. |
| SVN client | Talks to the operator-configured SVN server | Fixed argv; per-conversation branch switches still run inside the sandbox |
Database CLIs (psql, mysql, ...) | Connects to operator-configured databases | SQL travels on stdin, never argv; read-only guard at the tool layer |
| Ingestion / snapshots / SSH tunnel | Clone/fetch configured repos, snapshot workspaces, tunnel to the LLM host | Operator-trusted inputs only (configured URLs, fixed argv); these run outside any conversation, so there is no agent influence to confine |
The dividing rule, applied to all new code: any subprocess that executes or parses agent- or LLM-influenced content must be sandboxed; subprocesses that only transport content over a trusted, fixed-argv path may run host-side.
Network egress map
By design, the server process initiates outbound connections only to endpoints named in the operator's configuration:
- The LLM endpoint (OpenAI-compatible API) — for a self-hosted deployment this is your own vLLM/llama.cpp server, typically on localhost or over an SSH tunnel. Conversation content leaves the host only to this endpoint.
- The embedding endpoint (may be the same server).
- Configured git/SVN remotes (ingestion fetch, push gateway) and configured database servers.
- Optional operator-enabled integrations (e.g. web search provider, Telegram, OAuth providers) — each is a plugin you explicitly enable and configure.
There is no telemetry or "phone home" traffic: an air-gapped deployment with a local LLM server makes no external connections at all. Sandboxed agent code has no network unless you configure otherwise, so the egress list above is also the complete data-exfiltration surface to control at the firewall.
Data at rest
- All conversation, job, and plugin state lives in SQLite databases (WAL mode) under
data/; indexed repository content lives in the ChromaDB vector store on local disk. Nothing is stored in external services. - Per-user secrets, model profiles, and OAuth tokens are encrypted at rest (Fernet) with a key derived from
auth.jwt_secret. Settingjwt_secretis therefore mandatory in production — without it these stores fall back to plaintext and the server logs a prominent error. Key rotation is supported viaprevious_jwt_secretswith lazy re-encryption. - Sandbox overlay upper layers (agent-modified file copies) persist under the workspace base directory until their TTL expires — include them in retention and backup policy.
- Restrict all of these directories to the service user (
chmod 700); see Security → Data Protection.
Deployment prerequisites and recommendations
For the full checklist see Security; the architecture-relevant items are:
- Linux host with bubblewrap ≥ 0.8.0 with overlay support. On current distros the package is sufficient (
apt install bubblewrap); the bundled setup script builds from source only if it is not. On Ubuntu 24.04+ unprivileged user namespaces needusernsallowed for thebwrapbinary — theapparmorpackage's ownbwrap-userns-restrictprofile already covers/usr/bin/bwrap, so do not add a second profile namedbwrap. Verify withworkspace_isolation/setup/check.sh. - Enable
workspace_isolationand leave its defaults in place: network isolation on, minimal environment mode, external-agent tier on auto. - Run the server as a dedicated service account with no privileges beyond its data directories and the configured repositories — this account is the outer privilege boundary for both the server and the sandboxes.
- Keep the API on loopback behind a reverse proxy with TLS; authenticate via your directory (LDAP) and set
auth.jwt_secretfrom an environment variable. - Treat plugins and MCP server commands as trusted code — they run in-process. Enable only what you have reviewed.
See Also
- Security — hardening checklist and operational guidance
- Plugins in Production — workspace-isolation host prerequisites and per-plugin setup
- Self-Hosting Walkthrough — end-to-end multi-user deployment
- Architecture Overview — full component diagram (non-security view)