Security Engineering

Securing a Local Agent Daemon: Loopback Is Not a Boundary

David Guzenburg/ / 9 min read

It holds your model credential, it can read your repository, and it exists to modify source code. Every npm postinstall script on the machine can call it.

daemonlocal securityauthenticationpath traversal

Localhost is not a trust boundary

A local agent daemon listening on 127.0.0.1 feels safe. It is not reachable from the network, so the instinct is that only your IDE can talk to it.

Everything running as your user can talk to it. Every npm postinstall script, every VS Code extension you installed and forgot, every process a dependency spawned during a build, anything a browser page can reach through a permissive CORS header. A daemon with no authentication is an unauthenticated API on a machine that also holds your SSH keys.

And this particular daemon is worth attacking: it holds the model credential, it has whatever repository access you gave it, and it exists specifically to modify source code.

What an unauthenticated daemon hands over

CapabilityWhat a local process gets
Model API accessFree inference billed to your organisation
Repository readSource exfiltration through a component you built
Code modificationWrites into files, attributed to you
Context indexingA map of everything on disk it can see
Prompt injection surfaceWhatever the daemon does with returned text

The third row is the one that should decide your design. A daemon that writes files, called by any local process, means malicious code can modify your source through a channel your own tooling created — and the resulting commit carries your name.

A token, written where only you can read it

The minimum viable control. Generate a token at daemon start, write it to a file with restrictive permissions, and have plugins read it from there.

import os, secrets, stat
from pathlib import Path

def issue_token() -> str:
    token = secrets.token_urlsafe(32)
    path = Path.home() / ".internal-agent" / "token"
    path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)

    # Create with 0600 from the outset. Writing then chmod-ing leaves
    # a window where the file is world-readable.
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        f.write(token)

    mode = path.stat().st_mode
    if mode & (stat.S_IRWXG | stat.S_IRWXO):
        raise RuntimeError(f"token file is group/world readable: {oct(mode)}")
    return token
import hmac
from fastapi import FastAPI, Header, HTTPException

app = FastAPI()
TOKEN = issue_token()

def require_token(authorization: str = Header(default="")) -> None:
    prefix = "Bearer "
    supplied = authorization[len(prefix):] if authorization.startswith(prefix) else ""
    # compare_digest, not ==. String comparison leaks length and prefix
    # through timing, and a local attacker can measure precisely.
    if not hmac.compare_digest(supplied, TOKEN):
        raise HTTPException(status_code=401, detail="bad or missing token")
Better still, drop TCP

A unix domain socket with 0600 permissions is enforced by the kernel rather than by your comparison logic — no port to squat, no CORS to misconfigure, no browser page that can reach it. If both your plugin platforms can speak to one, prefer it. Windows named pipes give the equivalent.

Port squatting, and why the token also protects you

A fixed port is claimable. A process that starts before your daemon can bind 9090 and receive everything your plugins send — including your source code, and the token itself if the plugin sends it blindly.

The defence is mutual: the daemon writes its port and token into the same protected file, and the plugin verifies it is talking to the real daemon before sending anything sensitive.

{
  "port": 51473,
  "token": "…",
  "pid": 48211,
  "started": "2026-06-23T09:14:02Z",
  "protocol": 3
}

An ephemeral port written to a protected file is strictly better than a well-known one. Nothing can squat a port it cannot predict, and the plugin does not need to know it in advance — it reads the file.

Bind explicitly, and check what you bound

# Reachable from the network. On a coffee-shop wifi, by anyone on it.
uvicorn.run(app, host="0.0.0.0", port=9090)

# Loopback only.
uvicorn.run(app, host="127.0.0.1", port=0)   # 0 = kernel picks a free port

0.0.0.0 appears in a great many tutorials because it is what you want inside a container. On a laptop it exposes the daemon to the local network. Assert the binding at startup rather than trusting configuration, and refuse to serve if it is wrong.

Constrain what the daemon will do, not only who may ask

Authentication answers who is calling. It does not limit what a legitimate caller — including your own IDE, steered by injected content — can make the daemon do.

ALLOWED_ROOTS = [Path.home() / "work"]
DENY = {".env", ".git", "id_rsa", "credentials", ".npmrc", ".pypirc"}

def check_path(raw: str) -> Path:
    p = Path(raw).resolve()          # resolve() defeats ../ traversal
    if not any(p.is_relative_to(r.resolve()) for r in ALLOWED_ROOTS):
        raise HTTPException(403, "path outside allowed roots")
    if any(part in DENY for part in p.parts):
        raise HTTPException(403, "path is on the deny list")
    return p

The resolve() call is the important line. Without it, ~/work/../../.ssh/id_rsa passes a naive prefix check. Path traversal is an old bug and it reappears reliably in new components.

Two further limits worth having from the start: a request size cap, so one call cannot ship your entire repository to the model, and a per-hour spend ceiling, so a looping caller fails rather than producing a bill.

Prompt injection reaches the daemon too

One consequence of centralising is that the daemon becomes the place untrusted content converges. A refactor request carries source that may include a dependency's comment; an explain request carries whatever the user selected.

That makes the daemon the right place to enforce the split between reading and acting, because it is the only component that sees every request:

READ-ONLY endpoints  — /v1/explain, /v1/review, /v1/search
  may see anything the allow-list permits
  return text; never write to disk

WRITE endpoints      — /v1/refactor, /v1/apply
  the response is a proposed patch, never an applied one
  the PLUGIN shows a diff; the human accepts it

Returning a patch rather than writing the file is the structural version of the same control the rest of this series argues for. The daemon proposes, the human accepts, and no amount of injected text in the input can turn a request for an explanation into a silent modification.

Log enough to reconstruct a bad day

{"ts":"2026-06-23T09:41:22Z","endpoint":"/v1/refactor",
 "client":"vscode-1.4.2","pid":48555,
 "path":"/Users/j/work/api/src/billing.py",
 "bytes_in":2841,"tokens_out":612,"status":200,"ms":3140}

The pid field earns its place. When something calls the daemon that should not have, that is what tells you which process did it — and it is the only field that answers the question you will actually be asking.

A short checklist

  1. Bind to loopback or a unix socket. Assert it at startup.
  2. Ephemeral port, written with the token to a 0600 file.
  3. Constant-time token comparison on every request.
  4. Path allow-list with resolve(), plus a deny-list for credential filenames.
  5. Request size cap and a spend ceiling.
  6. Structured log including the calling process.
  7. No CORS headers. A browser page has no business here.

All seven fit in an afternoon, and they close the gap between a daemon that is convenient and one that is a local privilege-escalation path you built yourself.

Takeaway

Every process running as you can reach a loopback daemon, so it needs authentication as much as a public API would — a token in a 0600 file, compared in constant time, on an ephemeral port nothing can squat. Then constrain what the daemon will do regardless of who asks: path allow-lists resolved against traversal, size caps, and a spend ceiling.

Keep reading
Security Engineering

Hardening the Local Toolchain: Prompt Injection on a Developer Machine

Where untrusted text enters an agent's context on your own laptop, four controls ordered by return, and the session split that turns a possible compromise into a contained one.

Security Engineering

Implicit Trust Propagation: Why Provenance Dies in the Context Window

Content loses its origin the moment it enters the context, and tool chains launder it further. The tainted-session model, and how to split research from action.

Security Engineering

Unauthenticated Sampling: When an MCP Server Drives Your Model

MCP sampling lets a server request completions on your account, inverting the usual direction of control. Why it is a documented attack vector and how to constrain it.

Security Engineering

The MCP Threat Model: Where Trust Actually Breaks Down

Why the Model Context Protocol attracted 30+ CVEs and a DoD advisory within eighteen months: three protocol-level weaknesses, and why the whole context window is one trust domain.

← The MCP Threat Model: Where Trust Actually Breaks Down  ·  Reviewing Third-Party MCP Servers Before You Install Them →

All security engineering articles  ·  Every article