Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

08 — Local-first and runtime ownership

From cognokratos/sophos-agent · docs/runtime/08-local-first-and-runtime-ownership.md · pinned revision 8d9fe52182d8

Local-first is an architectural property of data flows, dependencies and control boundaries.

Stage R8 · What does local-first really mean? · Learning path · Previous: 07 · Then: walkthrough, challenges

Prerequisite: trust boundaries, network segmentation, and why "it's on a private network" is not authentication: simple-agent-template — Security and trust boundaries. This lesson is about ownership of data flows, not about identity.

Objective

Enumerate every data flow and runtime dependency of Sophos, say which stay on the machine and which leave it, and distinguish what is enforced by topology from what is merely configured.

Why it matters

"We run the model locally" is a statement about one component. Whether your conversations, tool results and learned facts stay under your control depends on every other arrow in the diagram: where prompts are sent, where state is written, which processes can open outbound connections, and which listeners other machines can reach.

Local model ≠ local system.

Mental model

flowchart LR
    subgraph host["Your machine"]
        BR["Browser"]
        subgraph web["Sophos process"]
            UI["SvelteKit UI + API"]
            AG["LangGraph agent"]
        end
        OL["Ollama<br/>OLLAMA_HOST"]
        MEM["Memory MCP"]
        FET["Fetch MCP"]
        DB[("sophos.db")]
        KG[("memory.jsonl")]
    end
    NET(("Internet"))
    REG(("npm / PyPI<br/>registries"))

    BR -->|"loopback"| UI
    AG -->|"prompts + full history + tool results"| OL
    AG --> MEM
    AG --> FET
    AG --> DB
    MEM --> KG
    FET ==>|"explicit egress:<br/>model-chosen URLs"| NET
    MEM -.->|"npx: package download on first start (stdio config)"| REG
    FET -.->|"uvx: package download on first start (stdio config)"| REG
FlowDefault destinationCarriesControlled byEnforced?
InferenceOLLAMA_HOST (http://localhost:11434; host.docker.internal in Compose)system prompt, every message of the thread, tool results.env / infra/.envNo. Point it at a remote host and all of that goes there.
Orchestrationin-process—codeYes (one process).
Conversation state, checkpointsDATABASE_PATH (local file)everything aboveenvIt's a path; a network mount would change the answer.
Long-term memoryMEMORY_FILE_PATH / Memory container volumelearned factsenv, infra/compose.ymlAs above.
UIloopback listenereverythingCompose 127.0.0.1:5173; Vite's default; HOST for node buildCompose: yes. node build without HOST listens on all interfaces.
Tool egressthe Fetch MCP, to any URL the model choosesthe URL (and anything the model encodes in it)mcp.jsonOnly by configuration. The Compose default network is not internal; every container can reach the internet.
Package resolution (stdio config: pnpm dev, the labs)npm / PyPI, when npx / uvx first start a serverpackage names and versionsconfig/mcp.json (pinned versions)No. Docker builds resolve dependencies at build time instead.
Tracing / telemetrynone—Sophos sends noneSophos sets none. LangChain.js libraries read tracing settings from the environment (LANGSMITH_TRACING); nothing in Sophos prevents a stray variable from enabling them.

Where it lives in Sophos

  • src/lib/agent/config.ts — getModelConfig(): OLLAMA_HOST, read once when the graph is built.
  • config/mcp.json — stdio servers for pnpm dev and node build (npx, uvx, pinned versions).
  • infra/app/config/mcp.json — HTTP servers on the Compose network.
  • infra/compose.yml — published ports (web only, on 127.0.0.1), volumes, and the default network.
  • 9) Security Posture — the reference for listeners, data at rest and the Fetch attack surface.

Experiment

Part 1: Remove the Fetch MCP

Use the lab environment. Create a configuration without Fetch:

mkdir -p data/lab/config-nofetch
cp config/system.md data/lab/config-nofetch/
jq 'del(.fetch)' config/mcp.json > data/lab/config-nofetch/mcp.json
cat data/lab/config-nofetch/mcp.json

Start the lab server with CONFIG_DIR=data/lab/config-nofetch in front of the usual command, and run one conversation that asks for a URL:

C=$(curl -s -X POST $B/api/chat -H 'content-type: application/json' \
  -d '{"message":"Fetch https://example.com and tell me what it says."}' | jq -r .conversation)
curl -sN "$B/api/chat?conversation=$C" > /dev/null
curl -s $B/api/conversations/$C | jq -c '.messages[] | {role, calls: [.toolCalls[]?.name]}'

Observed: no tool calls to fetch__fetch are possible; the model only has memory__* tools.

Part 2: Ask what network paths remain

Predict first. Write down every network connection the Sophos process and its children can still make. Then inspect:

# Listeners: who can connect to Sophos?
lsof -nP -iTCP -sTCP:LISTEN | grep -E 'node|ollama'

# Outbound: what is the Sophos process connected to right now?
lsof -nP -iTCP -sTCP:ESTABLISHED -a -p $(lsof -nP -tiTCP:5174 -sTCP:LISTEN)

# Where do prompts go?
grep OLLAMA_HOST .env

# Which MCP servers, over which transport?
cat data/lab/config-nofetch/mcp.json

# Is anything in the environment enabling library tracing?
env | grep -iE 'langsmith|langchain' || echo "none"

Observed (one machine): Sophos listened on 127.0.0.1:5174 only (because the lab sets HOST; restart it without HOST and look again). Its only established connection went to Ollama on [::1]:11434 (a kept-alive HTTP connection). The Memory server is a child process speaking stdio: no socket at all.

Look at the Ollama line, too. On the machine used for this lab, Ollama listened on *:11434, all interfaces, because of how it had been configured outside Sophos. Anyone on that network could use the model server directly. Sophos's loopback discipline ends at its own listener; the dependencies you run beside it are yours to own as well.

Questions to answer from what you found:

  1. If OLLAMA_HOST pointed at a GPU box on your LAN, what would leave the machine, and how much of each conversation?
  2. The first time npx started the Memory server, what did it contact?
  3. Without Fetch, can a prompt injection still exfiltrate data? (Consider what the UI renders: 9) Security Posture explains why model-produced images are shown as links and never loaded.)

Part 3: Reason about the Compose topology

The Compose stack is the configuration that is meant to be "local" for others. Read its effective configuration without starting anything (requires infra/.env, see Getting Started):

docker compose -f infra/compose.yml --env-file infra/.env config --format json \
  | jq '{networks, ports: [.services | to_entries[] | {service: .key, ports: .value.ports}]}'

Observed: only web publishes a port, on 127.0.0.1; the MCP services publish nothing (and scripts/test.sh asserts that). The only network is default, without internal: true.

So, from topology alone:

  • Enforced: nothing outside your machine can reach web, mcp-memory or mcp-fetch.
  • Not enforced: outbound traffic. Removing Fetch from infra/app/config/mcp.json removes the tool that makes requests, but web, mcp-memory and mcp-fetch can all still open connections to the internet. "Explicit egress" in Sophos is a property of configuration and tool design, not of the network.

What would you change to make egress enforced? (An internal network for web and mcp-memory, a second network only mcp-fetch joins, an egress proxy with an allow-list… and what happens to OLLAMA_HOST=http://host.docker.internal:11434 then?) This is design, not a change to make in Sophos today.

Compare three configurations

For each, say what leaves your control, and what an honest one-line description of the system would be:

ConfigurationInference dataConversation stateTool egressHonest description
local model + cloud tools
cloud model + local persistence
local model + local persistence + explicit egress(Sophos's default)

Hint for the second row: local persistence of a conversation whose every message was sent to a third party protects your copy, not the data.

Why the system behaves this way

  • One process, one host model server, local files keep the default data flows short enough to enumerate.
  • Loopback publishing is the one control that is enforced by the network rather than by configuration (5) Trade-off Decisions).
  • Fetch is the deliberate exception and is documented as the attack surface (9) Security Posture).

What this does NOT guarantee

  • Sophos keeps inference and state local by default, while configured tools may create explicit network egress. "Local" does not mean "no network".
  • Nothing restricts outbound connections from the process or the containers.
  • There is no authentication: anyone who can reach the listener can read every conversation and drive the tools.
  • Data at rest (SQLite, memory.jsonl) is not encrypted.

Takeaway

Local-first is an architectural property of data flows, dependencies and control boundaries.

Reason from the topology, not from the label. For every arrow, ask: where does it go, what does it carry, who configured it, and what stops it from going somewhere else?

Go deeper

This chapter is maintained in cognokratos/sophos-agent beside the code it teaches. The book shows docs/runtime/08-local-first-and-runtime-ownership.md at revision 8d9fe52182d8441454916ec8a6ab13c0773228e2 (branch main). View source at this revision · Report a correction.

Corrections are made upstream against the current main branch and appear here when the book's pin for this source is updated.