TypeScript and JavaScript were the two languages the new image could not serve. The daemon said why and kept going: `language server did not start … spawn EPERM`, once per root, while the other eight answered. socketpair was denied beside socket, on the reading that a served process must be able to obtain no socket fd at all. But libuv gives a child its stdio through a socketpair, so what that denied was not a network — it was Node starting a process, and typescript-language-server exists to run tsserver. A socketpair reaches nothing: both ends come back already connected to each other, in AF_UNIX, inside this jail's namespaces. It cannot be connect()ed elsewhere, and a peer that could pass a network fd over it would have to be outside the jail, where nothing is. socket() is still denied, so there is still no way to obtain a fd to a network. The filter now reads args[0] and admits AF_UNIX alone. Allowing it outright would have been equivalent today — the kernel implements socketpair for no other family — but that is a fact about the kernel, not a property of this program, and the claim in the header is worth keeping true by construction. The arithmetic is the risk: those two jump edges are hand-computed offsets over a list whose length changes with the phase, and an off-by-one reads as ALLOW. TestSocketpairIsDecidedByItsFamily interprets the program for AF_UNIX and for five families that must fail, in both phases, and pins that the carve-out did not widen to socket(). Co-authored-by: Hanzo Dev <[email protected]>
21 KiB
lsp — the jailed language-server daemon
ghcr.io/hanzoai/lsp · module github.com/hanzoai/lsp · Go, stdlib only · linux/amd64
Holds immutable per-(org, repo, commit) working trees, runs a language server
over each inside a jail with no network, and answers position questions about
them. It is the backend /v1/code/lsp/* resolves to.
Why it is a separate daemon
A static symbol index knows what a repository says about itself. It cannot tell
you where dep.Greet is DEFINED, because the definition is in a module the
repository only names. Finding it means downloading that module and
type-checking both halves — running a third-party binary over untrusted bytes.
That does not belong in the API process, so it is here, behind a jail.
The one answer that justifies the service is a Location with external: true:
a definition that left the repository and landed in the dependency cache.
The invariant
The network and untrusted bytes are never in the same process.
| FETCH | SERVE | |
|---|---|---|
| runs | go mod download — first-party toolchain, download-only |
gopls serve -mode=stdio — third-party, parses tenant source |
| socket | permitted | denied by seccomp (socket and the io_uring family; socketpair is allowed for AF_UNIX only, which is a pipe to your own child and reaches nothing) |
| tree | writable | read-only |
| dependency cache | writable | read-only |
| namespaces | new user/mount/pid/ipc/uts, minimal chroot, rlimits, no_new_privs |
same |
The phase is not a field anyone sets — it is jail.Fetch or jail.Serve, two
functions, so there is no value to get wrong. The only difference between their
seccomp filters is socket, and that is one two-line function (denied in
internal/jail/jail_linux.go).
socketpair used to be denied beside it and is not any more. It reaches nothing
— both ends come back already connected to each other, in AF_UNIX, inside this
jail's namespaces — but it is how libuv gives a child its stdio, so denying it
did not stop a network, it stopped Node starting a process. TypeScript's
server exists to run tsserver, so it died with spawn EPERM while every other
language kept answering. The filter now decides socketpair on its FAMILY, the
one argument it reads, so "no fd to a network" stays a property of the program
rather than a fact about which families the kernel implements
(TestSocketpairIsDecidedByItsFamily).
Fetches that would run DEPENDENCY-authored code (uv sync building an sdist,
npm install running postinstall, cargo build running build.rs) do not run at
all — see Fetchable in internal/lsp/langs.go. A language server resolves
definitions from source; build artifacts buy nothing and would be arbitrary code
executing in the one phase that has a network.
Fail closed
On Linux the jail is REQUIRED. At boot the daemon runs jail.Probe, which
builds a real jail in each phase and execs a canary in it — lsp canary,
this same binary — which asks the kernel for a socket and says what happened:
serving must be refused one, fetching must get one. Reaching the canary by exec
is the point: a jail that builds perfectly and then cannot exec fails the
self-test instead of passing it from inside the process that built it. If any of
that does not hold — no gVisor RuntimeClass, no user namespace, a masked mount,
a filter that did not take — /readyz is 503 and every request that would run a
language server is refused. There is no environment variable that turns the jail
off, so there is none that can be set by accident.
The mount order is the boundary too
The jail's filesystem is a VALUE — plan() in internal/jail/jail_linux.go —
ordered shallowest path first, with one mount per path and the jail's own /tmp
claiming that path before any caller can.
That ordering is not a detail. A mount whose path is an ancestor of an earlier
one covers it, and nothing fails when it happens: the earlier mount is still
there and simply cannot be seen. The daemon shipped with the writable /tmp
mounted AFTER the binds, which erased every bind under /tmp — and /tmp is
where the staging directory lives. The boot self-test masked its own working
tree, chdir returned ENOENT, the child exited 126, and /readyz was 503 on
every host. Nothing about the boundary was wrong; the filesystem was built in an
order that hid part of it.
Sorting by depth makes an ancestor-after-descendant order unrepresentable, and
TestThePlanHidesNothing asserts it with no kernel and no privileges.
What the jail brings with it
Three things are the jail's own, present in BOTH phases, and no argument can replace them (the first claim on a path wins):
| what | why | |
|---|---|---|
/tmp |
fresh tmpfs, writable, nosuid+nodev | the only thing a served process can write; dies with the process |
/proc |
fresh procfs in the jail's own PID namespace, read-only, nosuid+nodev+noexec | shows the jailed process and its children, nothing of the host |
/dev/{null,zero,full,random,urandom} |
bound from the host, writable | a user namespace cannot mknod; none of them holds state |
/proc is not a hole — it is a private one, and without it no Go program starts.
os.Executable reads /proc/self/exe, x/telemetry calls it on init, and gopls
died at startup with readlink /proc/self/exe: no such file or directory while
the daemon reported only server stopped. GOTELEMETRY=off does not prevent it:
x/telemetry reads its mode file, not the environment.
/dev/null is required for the same class of reason — Go's os/exec opens it
for every subprocess with no stdin, so without it go list cannot run. It is
bound writable because a read-only mount makes open(O_WRONLY) return EROFS.
hanzoai/code-exec carries the same ordering, and it is one change away from
the same bug. This jailer was copied from its internal/codeexec/jail_linux.go,
which also binds first and mounts /tmp after. It is not hit TODAY only because
it binds the session tree at a fixed /work rather than at the tree's own
absolute path, and its toolchain binds are outside /tmp; the day any bind path
falls under /tmp, it fails exactly the way this did. That is a property of a
call site, not of the jailer — which is the argument for the CTO call below: a
boundary that exists in two files drifts, and this is what the drift looks like.
Off Linux there is no jail to require, and the daemon says so loudly in the log
and on /readyz. The image is linux/amd64, so that case is a developer's
machine.
Roots are immutable
A root is (org, repo, rev) where rev is a resolved commit — 40 or 64 hex
digits, refused at the door otherwise. That single rule is the entire
cache-invalidation design: a sha names one tree for all time, a push is a
different key, and there is no subsystem that decides when anything is stale.
The caller resolves refs; it is the one that has the repository.
The daemon holds no git credential and makes no git request. The caller sends the tree. A daemon that could fetch a repository would need a credential that could reach every repository.
Wire contract
All bodies are JSON. Every route but the probes requires X-API-Key
(constant-time; unset key ⇒ 503, not an open door). org is a namespacing key,
not an authorization decision: this daemon does not authenticate tenants, it
isolates them. The caller behind the key — the cloud API — is the authenticator.
POST /root
{ "org": "acme", "repo": "app",
"rev": "9f2c1e...40 or 64 hex...",
"files": [ { "path": "go.mod", "content": "module …" },
{ "path": "main.go", "content": "package main…" } ] }
→ 200 { "ready": true, "cold": true, "langs": ["go"] }
Materializes the tree at /var/lib/lsp/roots/{org}/{repo}/{rev}, runs the fetch
phase once per module directory, starts one server per language that is both
present in the tree and installed in this image. cold is false when the root
was already live. Idempotent: roots are immutable, so re-sending a tree is a
no-op.
Errors: 400 bad key or tree (a path that escapes, a duplicate, an empty tree,
past the 20 000-file / 256 MiB bound), 409 on /root/warm only, 503
sandbox unavailable, 504 build timed out.
POST /root/warm
The same body and the same work, accepted only if this org already holds a live root for this repo. Pre-warming a revision nobody asked about is free work; it is offered only where a cold start on the same repository has already been paid for.
→ 409 { "need": "held", "org": …, "repo": … } otherwise.
POST /ask
{ "org": "acme", "repo": "app", "rev": "9f2c1e…",
"op": "locate", "relation": "definition",
"path": "main.go", "line": 5, "character": 9 }
op ∈ hover · locate · symbols · diagnostics · complete
relation (required for locate) ∈ definition · reference · type · implementation
Positions are the LSP's: line and character are 0-BASED and character
counts UTF-16 code units. An editor's 1-based line must have 1 subtracted before
it is sent. They pass through untouched — re-basing them would corrupt every
line containing a multi-byte character.
→ 200:
{ "op": "locate", "lang": "go",
"locations": [ { "path": "example.com/[email protected]/dep.go",
"external": true,
"range": { "start": {"line":3,"character":5},
"end": {"line":3,"character":10} } } ] }
Exactly one result field is populated: locations, hover, symbols,
completions or diagnostics. external: false (omitted) means path is
repo-relative. external: true means the answer left the repository and path
is its module coordinate — the dependency cache's own layout, which names the
module, its version and the file, and leaks no path belonging to this daemon.
A null result is not an error: "no definition here" is a real answer and comes back with an empty list.
→ 409 { "need": "tree", "org": …, "repo": …, "rev": … } when no root is held.
The caller then POST /root with the tree and asks again. Two calls cold, one
warm.
Errors: 400 bad key, unknown op or relation, negative position, a path that is
not in the tree, or a file no installed server answers for; 502 the language
server did not answer; 503 sandbox unavailable.
GET /healthz · GET /readyz
Liveness answers 200 while the process is up. Readiness answers what the jail
decided: { "ready": true, "jail": "jailed", "langs": [ … ] }, or 503 with the
reason. langs is the table narrowed to the servers this image actually has, so
it is also the honest answer to "what can you speak". They are different
questions and do not collapse into one.
Languages
internal/lsp/langs.go carries one entry per language — argv, root markers,
extensions, cache and fetch. What decides whether one is SERVED is
Lang.Available(): whether its binary is on PATH in the image. The table never
promises what the deployment cannot do.
| server | fetch | resolves dependencies from | |
|---|---|---|---|
go |
gopls | go mod download all |
the module cache |
typescript (.ts .tsx .mts .cts .js .jsx .mjs .cjs) |
typescript-language-server + tsserver | npm ci --ignore-scripts |
node_modules in the tree |
php |
intelephense | composer install --no-scripts --no-plugins |
vendor/ in the tree |
rust |
rust-analyzer | cargo fetch --locked |
the cargo registry cache |
python |
pyright | none — uv sync builds sdists |
source + bundled typeshed |
ruby |
ruby-lsp | none — bundle install compiles |
source + the rbs core signatures |
java |
jdtls | none — every Java resolver is a build tool | source + the JDK |
cpp (.c .h too) |
clangd | none — no resolver exists | compile_commands.json, else one file |
The four with no fetch still answer about the tree's own source and its
platform's; what they lose is a definition that crosses into a third-party
package. That is the scripts-off trade, taken deliberately: see Fetchable.
A server that fails to start takes only its own language down — build() logs
language server did not start and the rest of the tree keeps answering.
Bounds
| live roots | 4, LRU, 20-minute idle TTL |
| concurrent cold starts | 2 |
| one dependency fetch | 10 minutes |
| tree | 20 000 files, 256 MiB, depth 32 |
| serve rlimits | 6 GiB address space, 4096 fds, 256 procs, 2 GiB tmpfs, no CPU cap (total-CPU-seconds on a warm server is a scheduled death; the bound is the pod cgroup and the idle TTL) |
| fetch rlimits | 4 GiB address space, 900 CPU seconds, 1024 fds, 128 procs |
| one LSP frame | 32 MiB |
| handshake / query | 120 s / 30 s |
Configuration
| env | default | |
|---|---|---|
LSP_LISTEN |
:8000 |
|
LSP_KEY |
unset | shared service key, from KMS. Unset ⇒ refuse everything. |
LSP_ROOTS |
/var/lib/lsp/roots |
PVC |
LSP_DEPS |
/var/lib/lsp/deps |
PVC — public, checksum-verified modules only |
LSP_STAGE |
/tmp/jail |
emptyDir; each jail mounts its chroot base here |
LSP_PROXY |
https://proxy.golang.org,direct |
the ONLY host the fetch phase may reach |
LSP_SUMDB |
sum.golang.org |
verification is what makes a shared cache safe |
The dependency cache is shared across orgs on purpose. It holds only public modules verified against the checksum database, because this daemon carries no credential and so no private dependency can enter it. Sharing it is most of the cold-start win.
Layout
main.go boot: jail child hook, config, server, shutdown
internal/jail/ the jailer — COPY of code-exec's, see below
jail.go Spec, Limits, the two phases, the control channel
jail_linux.go the child: namespaces, chroot, rlimits, seccomp, exec
jail_other.go no jail off Linux, decided by platform not by flag
internal/lsp/
lsp.go types, placing an answer, decoding a server's reply
conn.go the LSP JSON-RPC client (PORTED, see below)
langs.go the language table (PORTED, see below)
root.go materialize · fetch · serve
pool.go LRU + idle TTL, single-flight build
http.go the door
Provenance
internal/lsp/conn.goandlangs.goare PORTED fromhanzoai/cloudbranchlsp(apps/lsp/server.go,apps/lsp/langs.go, commit67975187), with their tests. Two regressions that build pinned are pinned here too:Closehanging on a server that stopped reading its stdin, and a frame reader that would allocate whateverContent-Lengthit was told.internal/jail/is a COPY ofhanzoai/code-exec's jailer (internal/codeexec/jail_linux.goatd2794ce), generalized to two phases.
CTO call — the jailer should be one module
The seccomp filter now exists in two repositories. A security boundary that
exists twice drifts, and a change has to land in both. It should be
published as github.com/hanzoai/jail and imported by code-exec and lsp
alike. Publishing a new shared module is a call to make deliberately, so it is
noted here rather than made in passing.
This copy carries one fix over the original: the jail child now calls
runtime.LockOSThread() before installing the filter. no_new_privs and
PR_SET_SECCOMP apply to the CALLING THREAD, and syscall.Exec execs the
calling thread — if the Go scheduler moved the goroutine between the two, the
exec'd program would inherit no filter and the sandbox would silently not exist.
The original's sequence is short enough that it has always held in practice.
"In practice" is not a security boundary. This fix belongs upstream in
code-exec too.
Tests
go test ./... # everything; add gopls to PATH for the acceptance test
The acceptance test (internal/lsp/gopls_test.go) is hermetic: it publishes a
tiny module into a directory laid out as a Go module proxy and points the fetch
phase at it with GOPROXY=file://, so the REAL go mod download runs with no
network, and then requires locate definition on dep.Greet to come back
external: true at the module coordinate. It skips where gopls is not
installed.
It could not run JAILED until a file:// GOPROXY was bound into the fetch jail
(proxyDirs in internal/lsp/root.go). The jail binds an allow-list of host
paths, the proxy was not on it, and go mod download reported a module that does
not exist — so on Linux the test skipped via needJail and on a Mac it passed
unjailed. That is a real deployment shape too, not only a test's: an air-gapped
mirror on a PVC was a configuration the daemon accepted and could never read.
Verified end to end on the gVisor node: fetch, gopls start, locate definition
and hover all pass inside the jail.
The jail tests interpret the cBPF program the way the kernel would, so the jump
arithmetic — where an off-by-one silently ALLOWS a denied syscall — is checked
on any Linux with no privileges. TestThePlanHidesNothing, TestThePlanIsThePhase
and TestTheJailKeepsItsOwnTmp check the filesystem half of the boundary the
same way: no kernel, no privileges, no skip.
TestProbeProvesTheBoundaryOnThisHost runs the real thing and skips for
exactly one reason: this host cannot create the namespaces (jail.ErrHost),
which a plain CI container cannot and the gVisor target can. Every other failure
is this repository's and fails the build. It used to skip on any error at all,
so a jail that built and then did not work reported itself as "not the target" —
exit 126 skipped there for as long as it existed. TestMain in both test
packages takes the jail-child path, without which a jail re-execs the TEST
BINARY and a self-exec'd test suite proves nothing.
Phase 2
-
cloud
apps/lspbecomes a thin proxy. The blue monolith on branchlspran language servers inside the fleet pod with its own git checkout — the wrong shape, and superseded. Replace it with ~300 lines at/v1/code/lsp/*: validate the principal, take org from it, resolve the ref to a sha, read the tree throughapps/gitrepo.WalkText(SHARED withapps/code— one tree read, not a second checkout),Gate/MeterUsage(prepare price on cold, query price on warm), then POST tolsp.hanzo.svc:8000. On409 {"need":"tree"}, POST/rootand retry once. -
universe Deployment. DECLARED —
hanzoai/universecharts/app/values/hanzo/lsp.yaml:runtimeClassName: gvisor, non-root at uid 65532, RO rootfs, all capabilities dropped, no service-account token, seccomp RuntimeDefault, a 20Gi PVC for/var/lib/lsp, an emptyDir for/tmp, and an egress policy.LSP_KEYcomes fromhanzo/lsp/LSP_KEY@prodthrough thelsp-env-kms-syncKMSSecret; the same secret is read by cloud, so the proxy'sX-API-Keyand the daemon's key are one value, not a pair. Devs do notkubectl applyit — cd.hanzo.ai syncs that file.TWO THINGS GATE IT RUNNING, and neither is code here:
- The pool is too small.
runtimeClassName: gvisorresolves now —code-exec-poolis two s-4vcpu-8gb nodes labelledworkload=code-exec, tainteddedicated=code-exec, with runsc installed and the RuntimeClass merging both into this pod at admission. But ~6.2Gi allocatable against the 8Gi this daemon asks for means Pending onInsufficient memoryuntil the pool is resized. Onedoctlresize, no code. - The image. CI is
.hanzo/workflows/cicd.yml→hanzoai/cion the git.hanzo.ai runners, publishingghcr.io/hanzoai/lsp. The values file carries tag AND digest, bumped in a reviewed commit.
Note what the values file does NOT say: which nodes. The pool went from not existing to two tainted nodes while that file was being written, and it did not have to change, because naming the RuntimeClass names the node set by reference. A file that had copied the selector would now be wrong — and a nodeSelector that conflicts with the RuntimeClass's does not misplace a pod, it rejects it.
Note what does NOT gate it: a wrong node, a missing RuntimeClass or a kernel that will not make a user namespace are all survivable, because the boot-time probe refuses to be ready and the Service keeps no endpoints. The daemon never parses tenant bytes outside a jail it has watched work.
- The pool is too small.
-
MCP. EXTEND the existing single
lsptool (mcp/rust/src/tools/lsp_tool.rs,python-sdk/pkg/hanzo-tools-lsp) withrepo/revparameters — a file goes to the local stdio server, a repo goes to/v1/code/lsp/*. Not a new tool. -
More languages = install the server in the Dockerfile and add its entry. DONE for the nine above, and verified on the gVisor node at 0.1.6: Go, Python, Rust, C, C++, Java, PHP and Ruby all answered
locate definitionjailed, Ruby into the interpreter's own library withexternal: true. TypeScript and JavaScript did not, for thesocketpairreason above — one language's server failing is one language, which is the graceful-degrade design doing its job and the reason the other eight shipped anyway.Two things a repo needs before its dependencies resolve, neither a bug:
cargo fetch --lockedwants a committedCargo.lock, andnpm ciwants apackage-lock.json. Without one the fetch is skipped, logged, and the server still answers about the tree's own source.