Skip to content

fix: harden the values a project can put into generated files, and close the gaps around them - #1204

Merged
geodro merged 8 commits into
mainfrom
fix/post-1.30.1-review
Jul 28, 2026
Merged

fix: harden the values a project can put into generated files, and close the gaps around them#1204
geodro merged 8 commits into
mainfrom
fix/post-1.30.1-review

Conversation

@geodro

@geodro geodro commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes #1203

Four review passes over the v1.30.1 to v1.31.0-beta.1 window. Each finding was reproduced first, fixed, then re-measured on the ubuntu26.04 and omarchy VMs against the same reproduction.

The substance is that a checked-out project could decide what runs on the machine that links it. A node_version reached a host worker unit's command line, unquoted through fnm and inside a single-quoted message through nvm. A custom_workers entry's label, restart and schedule each reached a line of that unit, where only command was ever checked, and systemd ran an ExecStartPre one of them injected. A domains entry reached the vhost's server_name, where a semicolon and two braces close the server block and open another, and nginx -t accepted the result, so a project served its own block on a domain of its choosing. All three predate this window rather than arriving in it.

The fixes check values where the file is generated rather than field by field, so a field added later is covered without anyone remembering. Paths are treated differently from names, because they reach the template quoted and a directory legitimately called my#project;v2 still has to serve.

Around that: published tool pins are constrained to the hosts lerd fetches tools from and may carry a checksum, since that manifest reaches every install without a release; composer's version is read out of the phar rather than guessed, which fixes a card that showed a question mark on every install that had composer before stamping existed, and restores the update flag that silently required a known version; a sudoers drop-in is validated before it lands; and a handful of correctness issues in the service, database and IDE paths, including a base-image check that asked an unreachable registry again on every status rebuild.

MCP gained the three surfaces it was missing and now states what is deliberately absent, so an assistant stops looking for a way to share a site.

The README feature list is down to three or four lines an entry, and the framing about how lerd is built rather than what it does is gone from it.

Two things left deliberately. Sharing a site is still not exposed over MCP. And the em dashes in the strings lerd itself prints are untouched, since that is program output rather than docs and belongs in its own change.

geodro added 6 commits July 28, 2026 11:22
A project's committed .lerd.yaml supplies values that lerd interpolates into
files it generates, and three of those paths passed them through unexamined. A
node_version reached the command line of a host worker unit, where fnm took it
unquoted and nvm interpolated it into a single-quoted message; a custom_workers
entry's label, restart and schedule each reached a line of the unit, where only
command was checked; and a domains entry reached the vhost's server_name, where
a semicolon and a pair of braces close the server block and open another. Each
one let a checkout decide what ran on the machine that linked it.

Values are now checked where the file is generated rather than field by field.
Node versions must be version shaped before they reach a manager, and both
managers quote what they pass. Every value spliced into a worker unit goes
through one guard, so a field added later is covered without anyone remembering.
Every vhost renders through one helper that rejects the characters that end a
directive, and a domain carrying them is refused when the site is registered so
the error arrives at link time. Paths are checked only for a line break, since
they reach the template quoted and a directory legitimately named my#project;v2
still has to serve.

The database name in the existence check is escaped for the literal it lands in,
which the helpers deleted during the entity refactor used to cover.
… the phar

The pinned tool manifest is fetched from a branch before every download, so it
reaches every install without going through a release. A pin was accepted from
any host as long as the URL began with https, which made one file enough to
change what every machine downloads and marks executable. A published pin is now
honoured only when it points at a host lerd fetches tools from, extendable
through LERD_TOOLS_HOSTS, and may carry a sha256 per asset that the download is
checked against. Bytes land in a temporary file and are moved into place only
once complete, so a rejected or truncated download cannot replace a working
binary.

Composer's version was only ever knowable from the stamp sidecar, and that is
written only when lerd downloads it, which it does only when the file is absent.
Any install that already had composer.phar read as unknown forever and, because
the update check needs a known version, was never offered an update either. The
version is compiled into the phar, so it is read from the bytes rather than by
running it, and stamped on the way out so the read happens once.

One tool that cannot be updated no longer decides the fate of the others: each
is an independent download, so the rest are attempted and the failure is
reported at the end.
lerd bootstrap --system writes /etc/sudoers.d/lerd for whichever user it
resolved, and that name became the first field of every rule in the file with
nothing checking it. A name carrying a space produces a drop-in sudo rejects
outright, which loses the DNS grant it was meant to install.

The name now has to look like a user name, and the rendered file is put through
visudo before it is installed. A host without visudo still gets its grant, since
the rule shape is already constrained by then.
The base-image freshness warm-up invalidated the digest cache before reading it,
so a registry that could not answer was asked again on every status rebuild
instead of once per cache window. It now reads through the cache, and only the
manual check bypasses it.

A database import built its own container invocation and ignored the client
image the engine's entity declares, which export already honoured, so an engine
whose own image ships no tooling would export and then fail to import.

A pinned dependency host was rewritten as a bare substring, so an environment
value naming a versioned install of the same engine came out pointing at a host
that does not exist. The rewrite now stops at a name boundary.

The JetBrains sync wrote both files even when it had decided to leave the user's
own connection alone, which created an empty credentials sidecar in a project it
had just chosen not to touch and moved the other file's mtime for nothing.

The stop cascade gained a visited set. Dependents are matched by family and
env_role rather than by name alone, so a service whose own family satisfies one
of its depends_on entries is its own dependent, and the cascade did not return.

Also: an owner is resolved from a project's env values in a fixed order rather
than whichever the map yielded first, the tunnel builder answers with an error
instead of a nil command for a mode it does not know, and the worker-failure
detector seam is read under a lock because the flush runs on a timer.
…manager

Three capabilities had no tool at all. Postgres extensions were reachable only
from the CLI, so an assistant could not see what an engine offers or add one
before a dump arrives. Everything a preset declares that is not a database, the
buckets a RustFS install holds today, was reachable only from the dashboard.
And the reference described the fnm and nvm split while offering nothing to act
on it.

The reference also now says what is deliberately absent, so an assistant stops
looking: host tools are reported but applying an update is a CLI command, and
sharing a site is not exposed at all, since putting a site on the public
internet is the user's call to make.

Export and import on an entity stream a file, so they stay on the CLI and the
dashboard and the handler says so rather than half working. The manifest ceiling
moves for the added actions, with description verbosity in the same three tools
trimmed to pay for part of it.
…shes

The README's feature list had grown to where single entries ran past a dozen
rendered lines, one of them carrying six separate features in a single bullet.
Each entry is now three or four lines. What went with it is worth knowing: the
services entry lost several engines from its list along with db:move, the client
shims and the port auto-shift, so those are candidates for entries of their own
if you want them visible.

The list also described how lerd is built rather than what it does, so the
framework-agnostic and not-hardcoded framing is gone. That is true of the code
and interesting to whoever works on it, and it tells a user nothing.

Documented alongside the code they describe: the version shape every Node source
has to have, the worker fields that may not carry a newline, the hostname
characters a domains entry may not use, where the tool pins come from and what a
digests entry does, and that one tool failing to update no longer stops the rest.
Corrected the claim that a JetBrains connection carries its credentials in the
JDBC URL, which it does not and never did.

Em dashes are gone from the prose. Left in place where a page reproduces what
lerd prints, since those quote real output, and left in the changelog, which is
a record of what shipped.
@geodro
geodro requested a review from a team as a code owner July 28, 2026 08:28
…nning

Definitions exist for the recent majors only, so an older project borrows the
nearest and is marked guessed. The version switch clamped to that borrowed
range anyway, which refuses the PHP the project actually requires: on a Laravel
8 app, `lerd isolate 7.4` answered "7.4 isn't usable here" and moved it to 8.5.
Linking already declined to clamp a guessed definition, so the two disagreed
about the same project.

A definition now governs only when it is the real one for the detected version.
Otherwise, and when no framework was recognised at all, the constraint comes
from the project's own composer.json, which is what it says it supports. That is
also better than the previous no-clamp-when-guessed behaviour, since a project
requiring 8.2 can no longer be handed a PHP it cannot run.

Alternation is matched on either spelling. Composer accepts a single pipe and
the projects this matters for use it, Laravel 8 requiring "^7.3|^8.0", so the
whole constraint was read as one term that nothing satisfied. Detection then
fell through to the constraint's minimum and pinned 7.3 on a host running 8.5.
The guard script macOS writes embeds the version manager's prefix as a line of a
shell script, so it carries the same quoting the Linux unit does and its
assertion had to move with it.

The clean-write assertion read a systemd path, which does not exist on macOS,
and the refusal check looked for systemd unit filenames, so it passed there
without reading anything. The refusal now walks the tree, which is true on both
platforms, and what a written unit says is asserted where systemd is the target.
@geodro
geodro merged commit ecafbb9 into main Jul 28, 2026
3 checks passed
@geodro
geodro deleted the fix/post-1.30.1-review branch July 28, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Review the v1.31.0-beta.1 window and fix what it turns up

1 participant