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