Securing a Local Agent Daemon: Loopback Is Not a Boundary
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.
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
| Capability | What a local process gets |
|---|---|
| Model API access | Free inference billed to your organisation |
| Repository read | Source exfiltration through a component you built |
| Code modification | Writes into files, attributed to you |
| Context indexing | A map of everything on disk it can see |
| Prompt injection surface | Whatever 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")
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
- Bind to loopback or a unix socket. Assert it at startup.
- Ephemeral port, written with the token to a
0600file. - Constant-time token comparison on every request.
- Path allow-list with
resolve(), plus a deny-list for credential filenames. - Request size cap and a spend ceiling.
- Structured log including the calling process.
- 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.
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.