Security Posture
This document is the platform's security threat model stated as a set of
guarantees, each mapped to the executable assertion that enforces it. The
assertions live in one file — packages/cortex-cli/src/__tests__/security-regression.test.ts — so a change that quietly loosens a guarantee fails CI instead of shipping.
Why this file exists
During the 0.2.0 cycle, four latent security regressions were found as side effects of unrelated work, not by a test:
config.json(holds plaintext API keys) was created world-readable (0o644).- The tool access policy's default allow-list silently filtered out every MCP tool, so MCP was non-functional the moment it was restricted.
- A truncated edit dropped the
const apiTokendeclaration, disabling HTTP API auth entirely. - The provider retry wrapper was a no-op (a single 529 killed a run).
Each was a guarantee that existed in the code and the docs but had nothing holding it in place. The regression suite is that holding force. When you add a security-relevant behavior, add its assertion here too.
⚠️ Current status. Three sandbox modes, weakest to strongest: dev (no boundary), docker (a hardened container — a real but kernel-sharing boundary; see Docker mode), firecracker (MicroVM — the intended boundary, fully implemented but not yet validated on a Linux + KVM host). Until the Firecracker checklist passes, do not run untrusted prompts, code, or MCP servers in production; docker mode is the strongest option that runs on a developer machine today. Both isolated modes self-check and fail closed, so the platform never silently runs without its prerequisites.
Trust model
- Firecracker mode — INTENDED boundary, implemented but NOT yet validated.
The agent runs in a MicroVM with its own kernel and an isolated, read-only base
filesystem, launched via the
jailerwith cgroup caps and outbound-only NAT. The July 2026 audit found the path non-functional; it has since been fully implemented (see Firecracker isolation — implemented, pending KVM validation) but must be validated on a KVM host before being relied on as a boundary. - Docker mode — a container boundary (kernel-sharing). The agent runs in
a container as the operator's uid with every capability dropped,
no-new-privileges, a read-only image, Docker's default seccomp/AppArmor profiles, and memory/cpu/pids caps. A container escape requires a kernel or runtime vulnerability — a far higher bar than dev mode, a lower one than a MicroVM. On Docker Desktop (macOS/Windows) the container additionally sits inside Docker's own Linux VM. The host ↔ agent channel follows the daemon (ADR-009): a Unix socket where the daemon shares the host's kernel, a loopback TCP connection with a per-neuron token where it runs in a VM. See Docker mode. - Dev mode is NOT a security boundary. The agent runs as your user; a
prompt-injected agent can do anything you can. The CLI refuses to start in dev
mode without
NSYS_CORTEX_ALLOW_INSECURE_DEV=1as a deliberate acknowledgement. The defenses below are defense-in-depth that apply in all modes — they reduce blast radius, they do not make dev mode safe. - The neuron socket's client is whoever answers first. In the few ms between
the agent's connect and its first answer, another same-user connection
supersedes it (a prober landing before the agent must not fail the boot, and
the agent never reconnects); once a client has answered, further connections
are refused, and an unanswered connection's close is not the agent leaving. In
dev mode that is the agent hijacking itself; in firecracker mode the socket is
0600and owned by the jailed uid, so only the guest can reach it. - MCP servers are trusted code. MCP tools execute in the host process,
outside the neuron sandbox. Only connect servers you trust. (Tightening this
boundary is tracked as future work.) An
httpserver is reached over Streamable HTTP and authenticates with a bearer, fixed headers or an OAuth client-credentials token the host obtains and refreshes (ADR-016, #15); those credentials live in the 0o600 config file or the env, are masked byconfig getand redacted by the logger (token,clientSecret,Authorization), and widen nothing: the server stays trusted code. - Approvals are defence-in-depth, not a boundary. The agent loop's approval gate (ADR-012, #16) decides whether a gated tool call runs — a listed effect class, a tool whose policy requires approval,
elevatedat theasklevel — and never widens what the policy engine denies; a hook or a decision can only stop a call the policy would have allowed. The boundary around whatever does run is the sandbox mode's, as above.
Guarantees → tests
| # | Guarantee | Enforced by (test block) |
|---|---|---|
| 1 | The agent child never inherits host credentials — buildAgentEnv() allow-lists safe keys (PATH, HOME, NSYS_CORTEX_*, …) and denies anything credential-shaped (*_API_KEY, *_TOKEN, *_SECRET, AWS_*, SSH_*, …). Deny beats allow: even a NSYS_CORTEX_-prefixed var is dropped — both NSYS_CORTEX_API_TOKEN and NSYS_CORTEX_MCP_SERVERS (whose JSON value can carry MCP bearer tokens). |
agent env is scrubbed of host credentials |
| 0 | Tool arguments are validated against their declared TypeBox schema before the tool body runs. The agent loop JSON.parses untrusted model output and passes it to execute(); validateToolInput() coerces stringified scalars then rejects anything that still doesn't match — so a field declared Type.Number() (e.g. limit, pid) can't arrive as a string like "1; rm". This is the systemic backstop under the per-tool escaping below. In strict mode (agent.strictToolInput / NSYS_CORTEX_STRICT_TOOL_INPUT, ADR-011) no scalar is coerced at all and the validator's message is the tool's error result; the check is never bypassed in either mode. (Raw JSON-Schema MCP tools pass through — they talk to a server, not a shell.) |
tool input is validated against the declared schema |
| 1b | The read/search tools (read, grep, find, ls) and the memory tools shell-escape every model-controlled path/glob/pattern/name with shq(), coerce numeric fields (limit/context) to integers, and mark the pattern with -e + end option parsing with -- (so a value like --pre=/bin/sh can't become an rg flag, nor -x/bin/sh an fd --exec). These tools bypass the command policy entirely, so this is the only defense against injection from a nominally read-only agent. |
read/search tools shell-escape path and glob args; memory store escapes/validates names |
| 2 | Logs never carry a live key — redactString() scrubs known key shapes (sk-ant-, sk-, xai-, AIza…, AKIA…) and redact() masks any property named token/key/secret/password/authorization/cookie/credential. Verified end-to-end: a secret logged through createLogger() comes back redacted in the file, so the guarantee holds on the wiring, not just the pure function. |
secret redaction |
| 3 | Everything holding secrets or private data is created 0o600, private dirs 0o700: config.json (both the config-edit writer and createDefaultConfig), session transcripts + index, and log files/dir. |
sensitive files are not world-readable |
| 4 | The agent workdir is a jail: safeRealpath() resolves symlinks before the prefix check (defeats the workdir/escape → /etc/passwd symlink), and trailing-separator handling defeats the sibling-prefix trick (/work ≠ /work-evil). The policy engine denies filesystem access outside the workdir. |
workdir path jail |
| 4b | The read/search tools can't read outside the jail via a symlink. read reads through the validated proxy.readFile (file_read → assertPathInside) and slices in JS — it no longer sed/wc-shells the path. grep/find/ls resolve their search root's realpath in the neuron via the new resolve_path command (proxy.resolvePath → executor validatePath) and refuse to shell out if it escapes — closing the "an in-workdir symlink to /etc is readable" gap that checkPath's string glob missed. |
read/search tools reject a root that escapes the jail |
| 5 | The command deny-list can't be smuggled past a shell wrapper (bash -c "…"), command substitution ($(…)), base64-piped eval, or pipe-to-shell (curl … | sh) — the policy recursively unwraps and re-applies. No false positives on benign commands. |
command policy bypass resistance |
| 6 | elevated is never granted by any default preset; the readonly preset exposes no state-mutating tool — by effect class (ADR-011): its cap is read, so every active tool is read; the access policy caps the effect class a preset admits (standard → mutate-irreversible, so elevated or any other destructive tool is inactive even when registered; strict → mutate-reversible, so an MCP tool is inactive unless an allow entry names it exactly); MCP tools are allowed by default (dynamic names) but a group:mcp deny still blocks them. |
tool access and presets |
| 7 | When NSYS_CORTEX_API_TOKEN is set, every /api/ route requires a matching Authorization: Bearer <token> (constant-time compared); missing/wrong tokens get 401 with a { code, requestId } envelope that carries no message. |
HTTP API auth |
| 8 | The server refuses to bind a network-reachable host (anything but loopback v4/v6) unless a token is set — an unauthenticated, network-reachable API would expose neuron exec to anyone who can route to it. |
network bind guard |
| 9 | A 500 response never carries the error's message or stack — provider SDK errors can echo Authorization / x-api-key, so those go only to the log (keyed by requestId). The streaming /run route applies the same rule: agent-loop error events (which can carry Provider error: <msg>) are logged by requestId and replaced with a generic { code: 'RUN_ERROR', requestId } SSE frame rather than streamed raw. |
error envelope never leaks internals |
| 10 | A tool whose effect class the configuration lists (agent.approval.requireFor, default destructive), a tool whose policy requires approval, or elevated at the ask level never executes without an explicit allow on the agent loop's approval gate (ADR-012): with no approval surface installed it is denied at once, with a surface and no decision within the timeout it is denied, and a beforeToolCall hook's deny is final — the tool does not run even if an allow would have followed. The model receives denied: <reason> as the tool's error result. |
gated tools never run without an allow |
Guarantees enforced by tests in other suites
Some security guarantees predate this file and are pinned where they were built. They are part of the same posture; listed here so the map is complete:
- Vsock socket is
0o600, socket dir0o700(local socket impersonation defense) —cortex-host/src/__tests__/unit/vsock-server.test.ts. - HTTP rate limiting fires (per-IP budgets,
429envelope,=0disables) —cortex-hostrate-limiting unit test. - Request-body validation rejects injection (
InstallPackageSchemashell metacharacters,agentIdregex, image constraints) —cortex-hostvalidation unit test. apply_patchrejects paths that resolve outside the workdir (../and absolute) —cortex-tools/src/__tests__/tools/apply-patch.test.ts.config getredacts secret-shaped paths unless--secrets— decided byisSecretConfigPath()(cortex-sharedconfig-edit.ts) on the path's last segment (apikey/token/secret/password/authorization/credential; a plural…Tokensleaf is a count, not a secret) —cortex-sharedconfig-edit test and the regression suite's path case.- Executor
validatePathjails file I/O to the workdir — exercised by the tools E2E "workspace isolation" test (the executor delegates to theassertPathInsidecovered in guarantee 4).
Docker mode — container boundary (kernel-sharing)
NSYS_CORTEX_MODE=docker runs the agent in a container built from the same
image as the Firecracker rootfs — and it is what the default mode, auto,
resolves to whenever a Docker daemon and that image are present (otherwise
auto falls back to dev mode and the CLI says why; firecracker is never
chosen automatically). The docker run argv is the boundary
spec — it is built by a pure function (sandbox/docker-config.ts) and pinned
by docker-config.test.ts plus the security: docker sandbox hardening block
of the regression suite:
| Control | Flag / mechanism |
|---|---|
| Non-root | --user <operator uid>:<gid> — files it writes into the bind mount are the operator's |
| No capabilities | --cap-drop ALL; --security-opt no-new-privileges (setuid binaries can't regain any) |
| Syscall / MAC filtering | Docker's default seccomp + AppArmor profiles (never --privileged, never seccomp=unconfined) |
| Immutable image | --read-only; only /home/agent (bind mount) and /tmp (nosuid,nodev tmpfs, size-capped) write |
| Resource caps | --memory + --memory-swap equal (swap off), --cpus, --pids-limit — from NSYS_CORTEX_DOCKER_MEM_MIB / _VCPUS / _PIDS_MAX (1024 MiB / 1 / 512), never the Firecracker guest's NSYS_CORTEX_DEFAULT_* (#27) |
| Minimal mounts | exactly two: this neuron's home dir and its own socket dir — never the shared sockets parent |
| Control channel | unix (Linux Engine): a Unix socket in the 0o700 per-neuron dir; tcp (Docker Desktop): a loopback TCP port the container publishes on 127.0.0.1 only + a 32-byte token file (0o600) in that dir, presented by the host as its first frame — a wrong token closes, one connection is accepted; auto decides by docker info (NSYS_CORTEX_DOCKER_TRANSPORT); the token is never in the env |
| Network namespace | bridge (default) / none / user-defined; host and container: are refused |
| No host env leakage | allow-list env (locale/TZ/TERM + pinned HOME/USER/SHELL); no PATH, no NSYS_CORTEX_* host paths |
| Host-owned control state | .neuron.docker.json lives outside the bind mount; container name derives from the agent id |
| Fail closed | sandbox/factory.ts refuses to start unless the daemon answers and the neuron image exists |
What it does not give you:
-
Kernel sharing. A container is not a VM. A kernel or runc vulnerability reachable from an unprivileged, capability-less, seccomp-filtered process is what stands between the agent and the host. Treat docker mode as suitable for semi-trusted workloads; untrusted-by-design workloads still want Firecracker.
-
Egress filtering. The default
bridgenetwork reaches the LAN, the internet, and any host service bound on0.0.0.0. The Firecracker NAT's RFC1918/metadata DROPs have no docker-mode equivalent yet. Usenonefor offline agents or a user-defined--internalnetwork; a filtering egress proxy is the shared follow-up for both isolated modes. -
Docker Desktop (macOS/Windows) — the loopback TCP channel, unverified on Desktop itself. Where the daemon runs in a VM a host Unix socket cannot be shared with the container, so
autoselects thetcpchannel there (NSYS_CORTEX_DOCKER_TRANSPORT): the container publishes the agent's channel port on127.0.0.1only, the host writes a fresh 32-byte token owner-only into the per-neuron channel dir (mounted at/run/nsys-cortex, never in the environment) and dials with it; a wrong token closes the connection and one connection is accepted. Both channels are verified on Linux Docker Engine by the gated real-container suite (NSYS_CORTEX_DOCKER_TRANSPORT=tcp NSYS_CORTEX_TEST_DOCKER=1, 2026-10-30). The run on Docker Desktop itself is the operator's observation (spec hub Q6) and has not been recorded yet — until it is, the Desktop path is unverified. The offline network (none) has no port to publish and is available only with the Unix-socket channel. -
No per-neuron disk quota.
/home/agentis a bind mount of the host filesystem, so a neuron can fill the operator's disk; only/tmpis size-capped (tmpfs). The self-inspection tools say so instead of hiding it:neuron_statusreports the cgroup v2 limits and the capacity of the filesystem holding the workspace,neuron_memoryleads withmemory.max/cpu.max/pids.maxand labelsfree's numbers as the host view ("not a limit for this neuron"), andneuron_disklabels the workspace filesystem as shared with the host and shows the tmpfs/tmpand the root's read-only state. The figures are honest; the quota itself does not exist (#26).
Known limitations — defense-in-depth, NOT boundaries
A July 2026 adversarial audit (multiple independent reviewers, several rounds) confirmed the items below. The command/tool-layer items are defense-in-depth that would sit behind the VM boundary — but as the first item makes clear, that boundary is implemented yet still unvalidated on KVM, and in dev mode it is absent entirely, so today they are the only thing between an agent and your user account.
Firecracker isolation — implemented, pending KVM validation
The full production hardening has now been implemented in code (see the CHANGELOG "Firecracker production hardening" entry), but it has not yet been validated on a real Linux + KVM host — it cannot be exercised on the macOS dev machine, and the gated integration test (NSYS_CORTEX_TEST_FIRECRACKER) is not yet wired into a KVM CI runner. Until the validation checklist below passes, treat Firecracker mode as unverified and run only trusted workloads. The runtime self-check (sandbox/factory.ts) fails closed — it refuses to start firecracker mode unless KVM, jailer, root, cgroup v2, and (for NAT) iptables are all present — so the platform never silently runs without the boundary.
What was implemented:
- Boot + control channel. Guest agent reaches the host via a
socatAF_VSOCK↔UNIX bridge (Node has no native vsock); the host listens on the Firecracker guest-initiated<uds_path>_<port>path inside the chroot; boot args setinit=/sbin/init.sh;rootfs/init.shpivot_roots into the overlay so writes persist and drops to theagentuid. - Jailer. Firecracker is launched via
jailer— chroot, cgroup-v2 caps (memory.max/cpu.max/pids.max), seccomp, and a per-neuron unprivileged uid/gid. Kernel/rootfs/overlay are hard-linked into the jail; the API + vsock sockets live in the chroot, off/tmp. - Networking. Per-neuron point-to-point tap + /30 with outbound-only NAT; an egress firewall drops the host, sibling neurons, the LAN, and the cloud-metadata endpoint (
169.254.0.0/16), allows outbound + established replies, and drops new inbound. - Ceiling + lifecycle. A configurable max-concurrent-neuron ceiling (
CapacityError→ HTTP 503); destroy tears down the tap/cgroup/chroot and frees the uid/subnet; a host-crash sweep reclaims abandoned chroots + orphan cgroups.
Validation still required (see checklist at the end of the CHANGELOG entry / plan): boot a neuron on KVM, confirm pivot_root + vsock + overlay persistence, cgroup caps contain a fork/stress bomb, the egress firewall blocks host/metadata/siblings, and the gated integration test passes. Step-by-step host setup + the checklist: Installing Firecracker on Ubuntu 26.04 LTS.
Known limitations & scoped-out follow-ups (Firecracker mode)
A second, Fable-5-assisted review of the hardening code itself confirmed and closed the boot-blocking and boundary defects (co-located hard-link overlay staging with an EXDEV hard-fail; cgroup-v2-only with a parent-subtree_control delegation check; FORWARD/INPUT head-insertion + a per-tap default-deny + anti-spoof/rp_filter + IPv6-off + CGNAT/metadata/RFC1918 DROPs; a cross-process allocation lock; sun_path-length and socket chmod/chown hard-fails; jailer-id sanitization before any path is built). The following are known and deliberately out of scope for this pass:
- Outbound internet is a data-exfiltration channel. The VM boundary protects the host; it does not make agent data confidential. The egress firewall stops lateral movement (host/LAN/siblings/metadata) but not exfil to the open internet. Follow-up: force egress through a filtering proxy / DNS allow-list so agents reach only sanctioned endpoints.
- Snapshot / restore are disabled under the jailer. Both
snapshot()andrestore()throw "not yet supported" rather than run: a correct implementation must stage the snapshot/mem files into the chroot (host paths don't resolve inside the jail), integrity-verify them on load, and roll back cleanly on a failed restore. Tracked as a follow-up alongside snapshot integrity. AF_UNIXpath length caps the chroot base depth. The vsock socket path (<chrootBase>/firecracker/<id>/root/run/cortex-vsock.sock_9999) must fit in 107 bytes; the server rejects an over-long path early with an actionable message, but a very deepNSYS_CORTEX_CHROOT_BASEor long agent id will be refused. Keep the base shallow.- Neuron subnet lives in
172.16.0.0/16. If the host's real LAN overlaps that range, per-neuron/30addressing can collide; the egress firewall blocks the guest from reaching172.16.0.0/12regardless, so this is an addressing/routing concern, not a leak. Relocate the pool if your host LAN overlaps. - Guest
util-linuxships setuid binaries. The rootfs installsutil-linuxforpivot_root; its setuid helpers (mount,su, …) are not stripped. Inside the guest this is only a same-VM concern (the agent already runs code there), but stripping the setuid bit is defense-in-depth left for a rootfs follow-up. - Firecracker binary is not checksum-pinned by default.
setup-firecracker.shprints the download's SHA256 and enforces a pin only ifFC_SHA256_*is exported. Pin it from the release page before trusting the binary with root (supply-chain). - cgroup caps, chown, and the boot path are KVM-verify-only. They are statically correct and unit-tested, but the exact
memory.maxoverhead, the jailed-uid socket chown, andpivot_rootbehaviour can only be confirmed on a real KVM host per the checklist. - The orphan sweep is not invoked automatically.
killOrphanedNeurons()andcleanStaleJailResources()now identify VMMs correctly (by cgroup leaf — a cmdline pattern cannot, since jailer execs/firecracker --id <id> …), but nothing calls them at orchestrator startup yet; after a host crash run them (or reboot) before relying on the uid/tap pool being clean.
Defense-in-depth items (would sit behind the boundary)
- The command deny-list is best-effort and bypassable. It is regex-based, and regex command filtering fundamentally cannot catch every destructive form:
rm -r -f /usr(split flags),rm -rf /*,cd / && rm -rf usr,... | cat | bash,... | xargs sudo, newline/&-separated statements, and alternate encodings all evade it to varying degrees. Treat it as a guardrail against accidents, not an attacker. The real command boundary is the Firecracker VM. (Injection into the deny-listed tools is fixed — see guarantee 1b — but the deny-list's own coverage is inherently partial.) The(Fixed — see guarantee 4b.) They shell out togrep/find/lssearch tools follow symlinks.rg/fd/find/ls, which resolve symlinks at the OS layer; the fix resolves each search root's realpath in the neuron (resolve_path) and refuses an escaping root before shelling out. Residual: a symlink nested inside the searched tree is not followed byrg/fd/findby default, so root-resolution closes the reachable read path; the deepest guarantee remains Firecracker's filesystem isolation.apply_patchvalidates target paths as strings, not realpaths. An in-workdir symlink component (link → /etc) lets a patch write through it;patch(1)follows the link.apply_patchis only in thecoding/adminpresets, which also includebash, so it is not an escalation beyond what those presets already grant — but the tool's "resolves inside the workdir" claim is stronger than what the string check delivers.- A
baseUrloverride sends the provider API key to that host.llm.providers.<p>.baseUrl/NSYS_CORTEX_LLM_BASE_URLis not restricted to https or an allow-list; pointing it at an arbitrary host exfiltrates the key on the next call. This is operator-controlled config (trust domain), but there is no guardrail/warning today — set it deliberately. grep/find/lsconfine to the workspace via the policy layer, notresolve_pathalone.resolve_pathrejects escapes out of the executor'sallowedPrefixes, which in Firecracker mode (noNSYS_CORTEX_AGENT_WORKDIR) is the whole VM home (/home/agent/,/tmp/), not the workspace subdir. Workspace confinement comes from the (optional)PathPolicy— both shipped callers pass a workspace-scopedstandardpolicy, so it holds, but a caller that omits the policy or usespermissivewould let these tools read elsewhere in the agent's own VM. Not a boundary crossing (each neuron is its own VM; the VM home is the agent's sandbox), but pass a scoped policy for workspace-level confinement.
Related configuration
The operational knobs behind these guarantees — how to set the API token, enable rate limiting, bind a non-loopback host, tune logging — are documented in Configuration → Security. This file is the why and the proof; that file is the how.
Running the suite
npx vitest run packages/cortex-cli/src/__tests__/security-regression.test.ts
It needs no API keys, no network, and no NSYS_CORTEX_ALLOW_INSECURE_DEV — the
one test that boots the HTTP API does so on an ephemeral loopback port under an
isolated HOME, so the run is hermetic and safe in CI.