Nsys Cortex

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:

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

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:

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:

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:

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:

Defense-in-depth items (would sit behind the boundary)

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.