model persisted to /state; stop auto-allowing claude-code unfree

model persistence: /model <name> now writes to /state/hyperhive-model
(in-container), Bus::new reads it on init. operator override survives
harness restart and container rebuild; gone on --purge like every
other piece of agent state. path overridable via HYPERHIVE_MODEL_FILE
for tests. failure to persist is a warn, not fatal — runtime override
still applies, just won't survive a restart.

unfree opt-in: drop the auto-allowUnfreePredicate from
harness-base.nix and the claude-unstable overlay. operator now has to
set nixpkgs.config.allowUnfree (or a predicate listing claude-code)
in their own host config. silent unfree bypass was sketchy; this is
honest. readme + gotchas updated to spell out the snippet.

todo: drops model-persistence + container-crash + journald (all
shipped); adds per-agent send allow-list (constrain who an agent can
message).
This commit is contained in:
müde 2026-05-15 21:05:40 +02:00
parent 58c3cd853b
commit 8b9f7d21b7
6 changed files with 84 additions and 19 deletions

View file

@ -91,6 +91,17 @@ hive-c0re will then:
- auto-create the manager container (`hm1nd`) if missing, - auto-create the manager container (`hm1nd`) if missing,
- auto-rebuild any managed container whose hyperhive rev is stale. - auto-rebuild any managed container whose hyperhive rev is stale.
`claude-code` is unfree; hyperhive does not auto-allow it for you.
Add to your host config:
```nix
nixpkgs.config.allowUnfreePredicate =
pkg: builtins.elem (nixpkgs.lib.getName pkg) [ "claude-code" ];
```
(or `nixpkgs.config.allowUnfree = true`, your call). Each per-agent
container inherits this through the same nixpkgs evaluation.
## Build / deploy ## Build / deploy
```sh ```sh

20
TODO.md
View file

@ -3,6 +3,17 @@
Pick anything from here when relevant. Cross-cutting design notes live in Pick anything from here when relevant. Cross-cutting design notes live in
[CLAUDE.md](CLAUDE.md); high-level project intro in [README.md](README.md). [CLAUDE.md](CLAUDE.md); high-level project intro in [README.md](README.md).
## Permissions / policy
- **Per-agent send allow-list.** Today any agent can `send` to any
other recipient (peer, manager, operator). Add a per-agent
policy that constrains the `to` field — declared in `agent.nix`,
e.g. `hyperhive.allowedRecipients = [ "manager" "alice" ]`.
Broker rejects with an `Err { message }` when the policy denies.
Default: unrestricted (back-compat). The manager can still
always send anywhere. Useful for sandboxing untrusted sub-agents
so they can only talk to the manager, not other sub-agents.
## Security ## Security
- **Unprivileged containers (userns mapping).** Today the nspawn container - **Unprivileged containers (userns mapping).** Today the nspawn container
@ -31,15 +42,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
derived from the same config so the operator stays in control of derived from the same config so the operator stays in control of
what's exposed. what's exposed.
## Per-agent settings
- **Model override persistence.** `/model <name>` already switches
the model at runtime via `Bus::set_model`; the chip on the agent
page reflects the current value. Override is in-memory only and
resets on harness restart — by design for now, but consider
optional persistence (`/state/model` file?) so an operator-set
model survives a rebuild.
## UI / UX ## UI / UX
- **Terminal: `/model` slash command.** Operator-typeable model - **Terminal: `/model` slash command.** Operator-typeable model

View file

@ -53,11 +53,15 @@ socket without needing a clean reinstall.
## `claude-code` is unfree ## `claude-code` is unfree
`harness-base.nix` allow-list's it specifically. The flake pins it to The flake pins it to **nixpkgs-unstable** via
**nixpkgs-unstable** via `overlays.claude-unstable` (stable lags too `overlays.claude-unstable` (stable lags too far). The overlay
far). The overlay imports unstable with its own imports unstable inheriting the user's `nixpkgs.config`, so the
`allowUnfreePredicate` so the access inside the overlay doesn't operator must opt in by setting `allowUnfree = true` (or an
itself trip. `allowUnfreePredicate` that whitelists `claude-code`) on their host
config. hyperhive deliberately does NOT auto-allow — silent unfree
bypass would be sketchy, and the error message is clear enough that
the operator can fix it once and forget about it. Same on the
per-agent containers (they inherit through the same nixpkgs).
## Claude credentials are per-agent ## Claude credentials are per-agent

View file

@ -67,9 +67,14 @@
claude-unstable = claude-unstable =
final: prev: final: prev:
let let
# Inherit the *user's* nixpkgs config so allowUnfree (or an
# `allowUnfreePredicate` they set on their flake) propagates
# into the unstable import. hyperhive does not silently
# bypass the unfree gate — if the operator hasn't opted in,
# this overlay's `claude-code` access fails honestly.
unstable = import nixpkgs-unstable { unstable = import nixpkgs-unstable {
inherit (prev.stdenv.hostPlatform) system; inherit (prev.stdenv.hostPlatform) system;
config.allowUnfreePredicate = pkg: builtins.elem (prev.lib.getName pkg) [ "claude-code" ]; config = prev.config;
}; };
in in
{ {

View file

@ -24,6 +24,36 @@ const HISTORY_CAPACITY: usize = 2000;
/// `HYPERHIVE_EVENTS_DB` env var (used in tests and one-shot tools). /// `HYPERHIVE_EVENTS_DB` env var (used in tests and one-shot tools).
const DEFAULT_EVENTS_DB: &str = "/state/hyperhive-events.sqlite"; const DEFAULT_EVENTS_DB: &str = "/state/hyperhive-events.sqlite";
/// Persisted model name file. Same lifecycle as the events db —
/// survives destroy/recreate, gone on purge. Empty / missing file
/// falls back to `DEFAULT_MODEL`.
const DEFAULT_MODEL_FILE: &str = "/state/hyperhive-model";
/// Path to the persisted model file. Overridable via
/// `HYPERHIVE_MODEL_FILE` for dev / tests.
fn model_file_path() -> PathBuf {
std::env::var_os("HYPERHIVE_MODEL_FILE")
.map_or_else(|| PathBuf::from(DEFAULT_MODEL_FILE), PathBuf::from)
}
fn load_model() -> Option<String> {
let s = std::fs::read_to_string(model_file_path()).ok()?;
let name = s.trim();
if name.is_empty() {
None
} else {
Some(name.to_owned())
}
}
fn persist_model(name: &str) -> std::io::Result<()> {
let path = model_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(path, format!("{name}\n"))
}
fn now_unix() -> i64 { fn now_unix() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@ -177,11 +207,12 @@ impl Bus {
} }
}; };
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY); let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
let initial_model = load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned());
Self { Self {
tx: Arc::new(tx), tx: Arc::new(tx),
store, store,
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))), state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
model: Arc::new(Mutex::new(DEFAULT_MODEL.to_owned())), model: Arc::new(Mutex::new(initial_model)),
} }
} }
@ -193,9 +224,16 @@ impl Bus {
} }
/// Switch the model for future turns. The current turn (if any) /// Switch the model for future turns. The current turn (if any)
/// keeps the model it was already running. /// keeps the model it was already running. Persisted to
/// `/state/hyperhive-model` so the override survives harness
/// restart and container rebuild (gone on `--purge`, matching
/// every other piece of agent state).
pub fn set_model(&self, name: impl Into<String>) { pub fn set_model(&self, name: impl Into<String>) {
*self.model.lock().unwrap() = name.into(); let value: String = name.into();
self.model.lock().unwrap().clone_from(&value);
if let Err(e) = persist_model(&value) {
tracing::warn!(error = ?e, "model: persist failed");
}
} }
/// Update the harness's authoritative turn-loop state. Records /// Update the harness's authoritative turn-loop state. Records

View file

@ -7,7 +7,12 @@
boot.isNspawnContainer = true; boot.isNspawnContainer = true;
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ]; # `claude-code` is unfree. hyperhive intentionally does NOT auto-allow
# it — the operator opts in by setting
# `nixpkgs.config.allowUnfreePredicate` (or `allowUnfree = true`) in
# their own host config / agent.nix. Without that, the per-agent
# build fails on this package and the operator sees an honest "this
# is unfree, are you sure?" error.
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
hyperhive hyperhive