SIGN IN SIGN UP

fix(runner): fail-closed tool resolution and a minimal lease bind set

A Fleet declaring `tools: []` received every tool in the registry,
shell included. Two lines: child_exec_input collapsed a present-but-
empty array to null, and runner_helpers read null as "no preference"
and returned the registry default. The operator's declaration widened
access instead of narrowing it.

That matters because the lease sandbox shares the host network
namespace under the interim allow_all posture. Measured on the dev
host: eno3 with a public address and tailscale0 with 11 tailnet
routes, both reachable from inside a lease.

Three fail-closed changes, one boundary.

§1 A declaration can only subtract. The wire already normalises an
absent tools key to an empty slice, so "declared nothing" and
"declared []" were always one fact; the null collapse invented a
third meaning. Both arms now yield zero tools. hosted_tools.zig loses
both callers and is deleted (RULE NDC).

§2 HOSTED_TOOL_ALLOWLIST replaces a 7-entry denylist against a
25-entry registry, so shell/spawn/git/browser/web_fetch were
reachable by default and an upstream addition enrolled itself. A
comptime guard proves the allowlist is a registry subset. Three
refusal arms, and the order is the behaviour: a deliberately-unhosted
engine tool is fatal even though the registry cannot resolve it, an
unknown name is skipped as it always was, and a name that RESOLVES
but is not allowlisted fails the lease — the disposition the
scheduler refusal already had.

§3 The baseline drops from six broad trees to three binds and one
symlink. /etc carried the host account database; /opt carried the
daemon's own .env holding AGENTSFLEET_RUNNER_TOKEN, unreadable only
because its owning uid is not mapped into the sandbox userns — one
deploy-user change from readable. /usr leaves the landlock floor and
the bwrap argv too: the runner is statically linked on its own
single-file bind, so with no spawning tool a lease needs no
executable at all. Measured: /bin/sh, /usr/bin/sh, /bin/cat and
/usr/bin/env all answer `execvp: No such file or directory` inside a
lease, while the inference dial still succeeds.

Narrowing the baseline is what made SENSITIVE_PATHS' gap load-bearing
— it named /var/lib/agentsfleet while the deploy writes the token to
/opt/agentsfleet/.env, so the protection named a directory the token
does not live in. Added, because an operator bind could now reach it.

The resolver is a LINK, not a bind, and that was found by measurement
rather than reading. bwrap resolves a symlink when it binds, dropping
the target file into an /etc no landlock rule covers: the probe
reported resolver=0 dns=0 egress=0 — every lease without DNS, the
third incident of this exact shape after M167 and M136 §0. The
identical mount set with a symlink into the granted resolver
directory passes all three. A comptime guard now pins the target
inside the baseline.

protocol_bind.zig split at 384 lines along the seam it already had:
what the daemon owns (protocol_bind_paths.zig) versus what an
operator may add to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
K
Kishore Kumar committed
a10c0eebd005cd4abeb97ba18769f7e16cb01c75
Parent: 7003ea5