Skip to content

Security: Modsofthenation/Locus

SECURITY.md

Security

Locus is a local control plane. Treat it like a hypervisor plus an LLM keyring, not like a multi-tenant SaaS.

Report a vulnerability

Please use GitHub private vulnerability reporting on this repository. Do not file a public issue for a guest escape, token leak, or unauthenticated control-plane access.

What Locus tries to do

  • Bind the HTTP/WebSocket control plane to loopback (127.0.0.1 / ::1 / localhost only) unless Settings → Phone is enabled. Saving a non-loopback bind host in Advanced is still rejected. Phone remote binds 0.0.0.0 and requires a vaulted pairing token for every non-loopback client; public internet peers are rejected even with a token. The same port multiplexes loopback HTTP with LAN HTTPS (self-signed cert in ~/.locus/tls/, fingerprint on the pairing QR). Minting, rotating, and revealing that token is loopback-only. Restart after disabling phone remote so 7420 leaves 0.0.0.0. The locus CLI (apps/cli) is another loopback client of that API — same bind, redaction, and approval gates as the desktop.
  • Reject browser requests whose Origin is not loopback.
  • Keep QEMU display, SSH, RDP, WinRM, QMP, and serial forwards on loopback. VNC has no password and SPICE uses disable-ticketing; that is accepted for a single-operator loopback display, not as a network service.
  • Talk to LAN locus-worker hosts over HTTPS and pin the worker certificate fingerprint. Test connection observes the fingerprint; paste it into TLS fingerprint after matching the worker printout, then Save. Non-loopback workers require that pin on first save (no silent TOFU). WebSockets refuse to connect until a pin is saved. Cleartext worker URLs are rejected except loopback. Worker runtime:"host" (tools as sh -lc on the worker box) is off until LOCUS_WORKER_ALLOW_HOST=1. Worker QEMU guests keep VNC/SPICE on the worker loopback; the laptop reaches VNC through a pinned WSS proxy (/v1/guests/:id/display), never by publishing VNC. Cursor / Claude / Codex / Kiro on Linux or Windows VMs spawn over SSH with -R of the control-plane MCP port into the guest (PowerShell on Windows). The worker token is printed only on a TTY the first time it is created. Unauthenticated GET /health returns {ok,service,protocol} only.
  • Redact provider keys, forge tokens, guest passwords, and matching vault secrets in API responses, traces, session JSON, and tool output sent to the model. Tool-argument values are pattern-scanned, not only secret-named keys. GitLab glpat-, phone ph_, Chrome extension cx_, and Atlassian ATATT formats are included. GET /api/automations masks webhookSecret / slackSigningSecret; create/rotate still returns the secret once.
  • Store those secrets in ~/.locus/secrets.enc (AES-256-GCM). The packaged app wraps the master key with Electron safeStorage when the OS keychain is available, then deletes plaintext master.key.
  • MCP stdio servers inherit a minimal host env (PATH, locale, temp). They do not receive LOCUS_MASTER_KEY.
  • Generate a random guest password on first run. Ubuntu autoinstall identity uses a SHA-512 crypt of that password (not a hardcoded locus hash). Settings → Machines can rotate it (applies to the next install, not an existing disk).
  • Gate git_push, git_pr, and MCP tools from the agent loop on explicit Approve / Deny in chat, unless the session or default autonomy tier auto-allows them. MCP tools allowlisted on an automation skip that gate for that job only. Supervised asks for every mutating command. Guided (default) asks for push, PR, MCP, plugins, and package installs and has no default USD cap. Host-runtime Guided also asks for exec / powershell / start_process (those tools can reach the rest of this machine). Autonomous auto-allows push, PR, MCP, plugins, and installs, and requires a spend cap (default $20); protected default branches still wait.
  • Guest network jail defaults to restricted (forge + approved provider allowlist). VMs use restrict=on; Docker uses an HTTP(S) CONNECT proxy plus iptables OUTPUT DROP except loopback, Docker DNS (127.0.0.11), and the host proxy. CONNECT allow/deny is logged as egress with a byte count. Local loopback proxies (QEMU 10.0.2.2 / Docker on this machine) stay unauthenticated. Worker proxies bind 0.0.0.0 so host.docker.internal can reach them and require proxy basic auth; do not publish the ephemeral proxy port. An admin managed policy (/etc/locus/policy.json or signed ~/.locus/policy.json) can force this jail, allow-list providers, and disable Host agents / MCP / automations, Use my Chrome, phone remote, Autonomous, and Giphy lookup. Details: docs/managed-policy.md.
  • Host web search (websearch) is off until the operator enables it per agent. It fetches public search results from the host (DuckDuckGo / Wikipedia only) and bypasses the guest network jail. Results are wrapped as nonce-tagged <untrusted-data> so the model must treat them as data. Cursor CLI sessions use the same composer toggle: off strips Cursor's built-in webSearch / webFetch via agent --exclude-tools; on leaves those tools in Cursor's catalog. Review Cursor CLI sessions always strip write/edit/shell so the reviewer cannot patch the branch.
  • Wrap untrusted content (webhooks, web search, MCP / forge comments, operator load-path notes) in nonce-tagged fences. Tool output from outside the workspace is hostile by default.
  • Refuse file_read / grep / glob / file_list of credential paths (.env, cloud keys, SSH private keys). grep without a path still drops hits on those leaves. Known secret formats are redacted from traces and tool output. exec / powershell that dump process environment (printenv, env, /proc/*/environ, export -p) are refused.
  • Gate npm / pip / npx / apt / brew / similar installs, npm run lifecycle scripts, and package.json lifecycle scripts (postinstall, …) on Approve / Deny unless --ignore-scripts is used. The same gate applies to powershell.
  • Rate-limit failed phone-token and webhook-secret checks (20 / minute / peer). Debug traces (/api/debug/logs*) are loopback-only. Worker probes (POST /api/workers/test) and indexing an arbitrary Brain path (POST /api/brain/build with path) are desktop/loopback-only.
  • Log restricted-jail CONNECT allow/deny as egress events in ~/.locus/traces.

What Locus does not claim

  • Restricted jail is default-deny egress through a logged allowlist. Docker guests still do not keep NET_ADMIN on the long-lived container (iptables is applied via a privileged-exec helper). Missing iptables in a restricted guest fails the start instead of silently falling back to proxy env. QEMU user-net restrict=on is still the VM equivalent, not a hypervisor firewall.
  • Direct tool invocation from the guest pane is an operator action and does not go through chat approval.
  • Host MCP is the security exception: those servers run on the operator machine. Guest MCP (stdio) runs inside the Docker container. HTTP/SSE MCP stays on the host. Only add servers you trust.
  • Host automations (and host agents) run tools on the operator machine with no VM or container. file_* / apply_patch stay inside that session's /workspace; exec and git can reach the rest of the host. Treat that the same way as host MCP: opt in only when you want the job on this box.
  • Forge webhooks (and Slack / generic HTTP webhooks) are reachable only if something can POST to loopback or to a Tailscale Funnel that proxies only /api/hooks. An Amazon API Gateway HTTP integration may use the same /api/hooks path; set the webhook base URL to that origin. Locus unwraps REST/HTTP API proxy events. GitHub.com and Slack.com cannot reach 127.0.0.1. Generic X-Locus-Secret or x-api-key requests also need X-Locus-Timestamp (unix seconds, ±5 minutes) as a real HTTP header. A timestamp only inside an API Gateway JSON envelope is ignored, so Funnel/API Gateway integrations must forward that header. GitHub X-Hub-Signature-256 stays body-HMAC. Slack signing secrets can be set per automation (else Settings → Advanced). A leaked secret on a forwarded port would still be enough to queue a run within the timestamp window. Webhook bodies are wrapped as a nonce-tagged <untrusted-webhook-event …> block, size-capped (bytes, streamed), and must not be followed as instructions. First-party Jira/Confluence/Linear/Slack/Grafana/Miro/Figma/incident.io/AWS IAM/Portainer/Gmail/Calendar/Drive/Kubernetes/Sentry tools are off on automations unless listed on that job's allowlist.
  • First-party Jira / Confluence / Linear / Slack / Grafana / Miro / Figma / incident.io / AWS IAM / Portainer / Gmail / Google Calendar / Google Drive / Sentry / Kubernetes tools run on the host with vaulted tokens, like forge PR tools. They are omitted from the agent catalog until Settings → Integrations has a token for that product (AWS IAM also becomes ready when instance metadata is enabled; Kubernetes becomes ready with a token or a loopback kubectl proxy URL). Mutating Jira comments, creates, updates, transitions, Confluence pages, Linear comments/creates/updates, Slack posts, incident.io creates/updates, Miro export, Figma comments, Portainer start/stop, Gmail sends, Calendar creates, Drive uploads, and Sentry updates, Kubernetes apply/delete/scale wait for Approve / Deny. AWS IAM tools in this slice are reads (GetCallerIdentity, list/get/simulate). Kubernetes k8s_get of Secret objects redacts data / stringData. Cursor CLI sessions get the same tools through a loopback MCP server (POST /mcp); agent --approve-mcps is the operator opt-in for those calls. Claude Code sessions get the same server via project .mcp.json (type: http) and --mcp-config .mcp.json. The MCP config file stores only the loopback URL, not tokens. Worker Cursor CLI and Claude Code use a loopback proxy on the worker host that tunnels JSON-RPC back to this control plane — it does not bind the laptop MCP port on the LAN. AWS IAM/STS calls use signed fetch on the host (no AWS SDK), same as Fargate placement. Optional IAM/STS/ECS endpoint URLs and an AWS gateway URL keep their path; execute-api hosts sign as execute-api. HTTP MCP and OpenAI-compatible Bedrock / API Gateway URLs sign the same way when AWS keys are configured. The control plane bind host stays loopback. Instance metadata is IMDSv2 or the ECS credentials URI; AWS_CONTAINER_CREDENTIALS_FULL_URI must be link-local or loopback. Kubernetes tools talk to the API server (or loopback kubectl proxy) from the host; they do not run kubectl in the guest.
  • Integration plugins are the same host-side HTTP exception, authored by manage_integration_plugin (Approve / Deny). Secrets belong in Settings → Integrations, not in the manage tool. Updating a plugin keeps vaulted secrets; changing a secret field to a non-secret type drops the value instead of writing it to config.json. The request host must come from a plugin URL field or a hardcoded https host; argument values cannot retarget the host. Redirects are refused. Plugin responses are nonce-fenced. GET tools with write: false skip Approve / Deny (they are treated as reads); non-GET tools always wait. Automations only see listed plugin_* names.
  • Slack identity. Messages Locus sends default to you (composer or approved slack_post). Pass as="bot" or use the composer toggle to post as the Locus app when a bot token is stored. The Slack app is OAuth + Socket Mode plumbing. Socket Mode is outbound WSS; Slack.com still cannot reach loopback. Funnel stays /api/hooks only. Slack action buttons may Approve / Deny slack_post and slack_upload only — git_push and other forge writes stay in Locus. Thread excerpts are nonce-fenced (kind="slack-message"). The cache under ~/.locus/slack/ stays on this machine and is encrypted with the vault master key. Slack file GIFs and videos are fetched with the user token through /api/slack/media (Slack file hosts only). HTML and CSV files stay Content-Disposition: attachment on that route unless preview=1, which serves HTML with a strict CSP for a sandbox="" iframe (no scripts) and CSV as inline text for the table preview. Video is served inline for the in-pane player. GIF search is outbound to api.giphy.com when LOCUS_GIPHY_API_KEY is set and managed policy allows it; otherwise a local catalog.
  • Host web search also runs on the operator machine. Leave it off unless you want that agent to query the public web.
  • Use my Chrome is a second Host-runtime exception: an unpacked Chrome/Edge extension on loopback can drive the operator's real tabs (cookies and logins included) for Host agents that opt in. Isolated host-chrome stays the default. Pairing tokens (cx_) are vaulted like phone ph_. The websocket is loopback-only; navigation is an http(s) allowlist (file:, data:, chrome://, and other schemes are refused). Workers cannot use live Chrome.
  • Android pairing loads HTTPS only (cleartext disabled). The TLS fingerprint is required; a locus://pair deep link prefills the form and asks for confirmation before connecting. The token is stored as a cookie for the WebView, not appended to the HTTPS URL. Third-party cookies are off.
  • Electron keeps contextIsolation / sandbox / nodeIntegration=false, injects the control-plane CSP, and cancels will-navigate / will-redirect away from loopback. connect-src is loopback WebSocket hosts, not ws: / wss: wildcards.
  • New installs get a random guest password, including builtin templates merged into an existing config. Older configs may still use locus / Locus!dev1 until you rotate them. Anyone who can already talk to guest SSH on loopback can log in with those credentials.
  • Secrets live in ~/.locus/secrets.enc. The master key is ~/.locus/master.key (mode 0600) or Electron safeStorage (master.key.safe). The packaged app removes master.key after wrapping. A non-hex LOCUS_MASTER_KEY (a passphrase) is stretched with scrypt and a per-install salt in ~/.locus/master.salt (mode 0600); existing passphrase vaults migrate off the legacy static salt on the next successful decrypt. Disks, golden images, and recordings live under the same tree. Back that directory up like you would SSH keys. Settings → Advanced → Reset all data deletes this tree after two confirms.
  • The mock hypervisor is for UI development. It does not isolate workloads.

Secrets, privacy, and retention

  • Never put secrets in the prompt. Provider keys, forge tokens, and guest passwords live in secrets.enc. Git tokens are injected as GIT_TOKEN (and friends) at container/VM start. Guest git uses a credential helper that reads $GIT_TOKEN — the token is not placed on argv via http.extraHeader. Docker run/exec pass vaulted env through a 0600 --env-file, not -e KEY=value. Cursor CLI uses CURSOR_API_KEY in the child env, not --api-key. Settings → About states this policy in the UI. Guest cloud-init / Omarchy identity uses a SHA-512 crypt of the vaulted password (not plain_text_passwd, and Omarchy does not fall back to locus). Windows Autounattend still needs a plaintext password in XML (the format requires it); that file is mode 0600.
  • What goes to providers. Chat messages, tool results (after secret redaction), and screenshots when Vision is on are sent to the provider you enabled. Locus does not send traces, vault files, or master.key.
  • Training. Locus does not train models. Whether a provider trains on API traffic is that provider's policy; use your provider's zero-retention / API-data controls when you need them.
  • Retention. Session JSON, traces, screenshots, recordings, and disks stay under ~/.locus on this machine until you delete the session or Reset all data. Automations that open a new chat per run keep the newest 60 chats for that job (pinned and in-flight chats stay) and delete the rest from this machine. There is no cloud copy of the control plane.
  • Isolation. This is a single-operator local app. Each agent has its own guest (Docker workspace or VM disk). That is workspace isolation, not multi-tenant SaaS tenancy. Do not run untrusted operators against one ~/.locus.

Reliability

  • A turn snapshots the guest (or /workspace) before the user message is appended. Undo / rewind restores that checkpoint so cancelling a mid-edit agent leaves a consistent tree.
  • Completed tool results are persisted in the session JSON as they finish. If the control plane dies mid-turn, the next boot parks running sessions and appends a recovery note; send a message to continue. Replaying the same tool-call id skips work already done.
  • Compaction summarize-and-prunes old turns but re-injects the session plan, task_list, and scratchpad (and an explicit in-progress list) so a fold cannot drop the plan mid-surgery.
  • Diagnosable tool failures trigger a self-correct nudge (read the output, form a hypothesis, retry) instead of stopping for the operator. Cap is two nudges per turn. Traces record selfCorrect plus approval waits on turn.end.
  • Every turn is JSONL under ~/.locus/traces/. Failed turns also write ~/.locus/evals/*.json so a regression from last Tuesday is a fixture, not a story. The agent ⋯ menu → View trace can copy or save that agent’s JSONL as an eval, and shows an egress summary plus an approvals export (GET /api/sessions/:id/audit/egress|approvals, loopback-only like /api/debug/logs*).
  • Model catalogs use exact ids, not *-latest. Settings → Providers → Test connection probes the adapter. Compare models A/B-tests a prompt on a second model before you blame the harness. A daily harness-smoke workflow re-runs the wrapping, redaction, supply-chain, and prompt-variant tests.

Practical rules

  1. Do not expose port 7420 (or guest forwards) on the public internet. Tailscale Funnel, if used, must proxy only /api/hooks to 127.0.0.1. Phone remote is LAN/Tailscale plus a pairing token — still not a Funnel of the control plane. The Chrome extension talks only to loopback. Keep Settings → Advanced bind host on loopback.
  2. Guests start restricted. Keep none for the tightest guests; Open is only for first-boot apt unless a managed policy forbids it.
  3. Keep forge tokens and provider keys in Settings; do not paste them into chat. The agent cannot read .env or cloud credential files into context.
  4. Review Approve/Deny for push, PR, MCP, Jira comments/creates/transitions, Linear comments/creates/updates, Slack posts, Sentry updates, Kubernetes apply/delete/scale, integration plugins, and package installs that can run postinstall scripts — or set Autonomy to Supervised if you want every mutating command, or Autonomous (with a spend cap; default $20) for unattended CI. Autonomous also auto-allows MCP, plugins, and installs. Host-runtime Guided also waits on exec. Guided/Supervised have no default USD cap. Automation MCP / integration allowlists are an explicit operator opt-in. Stop / take over / rewind cancels a pending gate.
  5. Treat automations that start a VM as able to spend CPU, disk, and API tokens until you pause them.
  6. To sign macOS dmgs, set repo secrets CSC_LINK (base64 p12) and CSC_KEY_PASSWORD. Unsigned builds still ship.

There aren't any published security advisories