diff --git a/docs/trust-boundary/security.md b/docs/trust-boundary/security.md index 467cc5fa..2db0f87f 100644 --- a/docs/trust-boundary/security.md +++ b/docs/trust-boundary/security.md @@ -200,6 +200,10 @@ to socket at '/nix/var/nix/daemon-socket/socket'`, which names the cause. The agent's `/nix/store` and its read-only nix database stay bound, so store paths already there stay readable; only a realisation of a new path, or any `nix` command that needs the daemon, fails. + The agent's own NixOS config turns its `nix-daemon` socket and service off in + this mode: the tmpfs is writable, so the unit would otherwise listen on it. + Under `host` the directory is the host's read-only bind, which the socket + unit's `ConditionPathIsReadWrite` rejects, so that unit never starts. The mode reaches the container as `privateUsers` does: the swarm builder reads the option from the evaluated config into the agent's closure record diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index 6b1dafce..3582ab7b 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -153,6 +153,8 @@ in `nix build`, `nix run`, `nix shell` and the like fail for anything not already in the store, with a "cannot connect to socket" error. The agent's own system closure stays readable. + The container's own nix-daemon socket and service are off, so nothing + in the container listens on that path either. The swarm builder reads the option from the evaluated config into the agent's closure record, and the hive applies it when it next starts the @@ -267,6 +269,19 @@ in # read-only filesystem. systemd.globalEnvironment.NIX_REMOTE = "daemon"; + # Under `nixBuilds = "none"` the daemon directory is an empty tmpfs, which + # the nix-daemon socket unit's `ConditionPathIsReadWrite` accepts, so the + # container's own daemon would listen there and hand out the very socket + # the mode withholds. Under `host` the directory is the host's read-only + # bind, the condition fails, and the unit stays inactive. `nix.enable` + # stays on, so the client and the read-only store remain usable. + systemd.sockets.nix-daemon.enable = lib.mkIf ( + config.services.hyperhive.agent.nixBuilds == "none" + ) false; + systemd.services.nix-daemon.enable = lib.mkIf ( + config.services.hyperhive.agent.nixBuilds == "none" + ) false; + # `claude-code` is unfree. Each per-agent container's nixosConfiguration # evaluates its own `nixpkgs` instance, so the operator's host-level # `nixpkgs.config.allowUnfreePredicate` does not propagate into here — diff --git a/nix/module-eval/agent-user.nix b/nix/module-eval/agent-user.nix index 47a2024f..8601f285 100644 --- a/nix/module-eval/agent-user.nix +++ b/nix/module-eval/agent-user.nix @@ -134,6 +134,17 @@ let && noBuilds.systemd.globalEnvironment.NIX_REMOTE == "daemon" && !(builtins.tryEval unknownBuilds.services.hyperhive.agent.nixBuilds).success; } + { + # The container's own daemon would listen on the tmpfs that hides the + # host socket. The `host` agent is the control: its units are on, so a + # misspelled unit name cannot pass the `none` arms. + name = "nixBuilds none turns the container's own nix-daemon socket and service off, host leaves them on"; + ok = + iris.systemd.sockets.nix-daemon.enable + && iris.systemd.services.nix-daemon.enable + && !noBuilds.systemd.sockets.nix-daemon.enable + && !noBuilds.systemd.services.nix-daemon.enable; + } ]; in runGroup "agent-user" cases