Control plane
Non-production by design. No TLS — tokens are cleartext on the wire. No replay/nonce cache — a captured token is replayable until it expires (hence the short 900-second default TTL). No revocation propagation beyond one local file. One harness per process. Binds to
127.0.0.1by default;hiveloom control-planewarns loudly on stderr if you point it anywhere else. Put a reverse proxy or an SSH tunnel in front if you need this reachable from off-box, and never expose it directly to the open internet.
hiveloom control-plane <harness-dir> exposes a deployed harness's CLI surface over
HTTP, bearer-authenticated with the ed25519 keys described below — a
selected operational subset, not everything hiveloom can do. Some
verbs are deliberately absent (package, generate, trust) and some
spec roots can never be reached remotely at all regardless of scope — see
below. MCP server administration in particular is local-only: hiveloom set mcp_servers ... and friends are a construct-API/CLI operation, never
an HTTP one.
Identity: ed25519 keys + bearer tokens
Built at the user's request for "quick non-production asymmetric bearer tokens." Deliberately minimal — read the limitations at the bottom before relying on it for anything that matters.
Custody model
- A member runs
hiveloom keys generate <name>on their own machine. The private key (<name>.pem, mode 0600) never leaves that machine. - The member runs
hiveloom keys sign --key <name>.pemwhenever they need a bearer token, and sends the token (not the key) to whoever needs it. - The operator of the deployed harness runs
hiveloom keys authorize <name> <public-key> --harness <dir>on the deploy box, using the public key the member shared — never the private key. - Revocation (
hiveloom keys revoke <key-id> --harness <dir>) and listing (hiveloom keys list --harness <dir>) also run on the deploy box, against that harness's<dir>/.hiveloom/authorized_keys.jsonstore.
hiveloom keys verbs
| Command | Runs on | Effect |
|---|---|---|
keys generate <name> [--out-dir ~/.hiveloom/keys] |
member's machine | Writes <name>.pem (0600, refuses to overwrite); prints the public key and its key_id. |
keys sign --key <path> [--subject] [--scope] [--ttl] |
member's machine | Mints a bearer token (default TTL 900s / 15 min; --ttl is capped at a 7-day server-side ceiling — a longer one is refused at mint and at verify). |
keys authorize <name> <public-key> --harness <dir> --scope <s> ... |
deploy box | Authorizes a public key. --scope is required (no default) — a key is never granted broad scope by omission; pass --scope '*' only for a fully-trusted admin key. Idempotent on key_id: re-authorizing un-revokes and updates scopes. |
keys revoke <key-id> --harness <dir> |
deploy box | Marks a key revoked. The row is kept, not deleted (audit trail). |
keys list --harness <dir> |
deploy box | Lists authorized keys (including revoked ones). |
All five support --json. The store path defaults to
<harness_dir>/.hiveloom/authorized_keys.json and can be overridden with
--authorized-keys PATH or $HIVELOOM_AUTHORIZED_KEYS.
A public key is base64url, whose alphabet includes -. About 1 in 64
freshly generated keys start with one, which a shell/CLI argument parser
can misread as an option flag (No such option: -a) rather than the
<public-key> value — this is a standard Click/argparse ambiguity, not
specific to hiveloom. If keys authorize rejects an otherwise-correct
invocation this way, put -- before the positional arguments so the
parser stops looking for options: hiveloom keys authorize --harness <dir> --scope run -- <name> <public-key>.
Scopes are coarse by design — run, read, mutate, evolve, * — with no
per-route ACLs. A token cannot grant more than its authorizing key holds:
both the key's scopes and the token's own scope claim must cover whatever
is required.
Serving a harness
hiveloom control-plane <dir> [--host 127.0.0.1] [--port 8420]
[--max-concurrent-runs 1] [--max-queued-runs 4]
[--authorized-keys PATH] [--approve]
Trust is checked exactly once, before the socket binds — never per request.
A non-interactive invocation (no TTY on stdin, e.g. systemd/CI/Docker)
refuses to start against an untrusted harness folder rather than hang
waiting for a prompt that will never come; pass --approve or trust the
directory beforehand with hiveloom trust <dir>.
Endpoints
Every endpoint maps 1:1 onto an existing library function — the CLI and the
HTTP layer share one implementation of each behavior, never two. Bodies are
JSON; responses follow the same {"ok": ...} shape the CLI's --json output
uses.
| Method/path | Scope | Wraps |
|---|---|---|
GET /health |
none | Harness name, spec version hash, evolved counter. |
POST /run |
run |
runner.run_harness. ?stream=true → SSE of trace events, ending in a run_result frame. |
GET /stats |
read |
Hive summary + recent failures. |
GET /trace/{run_id} |
read |
Hive lookup + trace read, bound to the served harness; 404 if unknown or if the run belongs to a different harness (the Hive is global — see below). |
POST /validate |
read |
Full spec + code-hook validation. |
POST /set |
mutate |
Set one dotted field to an already-typed JSON value (construct.set_value) — refused for any ALWAYS_FROZEN root; see below. |
POST /add/{tool,validator,guardrail,hook,skill} |
mutate |
The matching construct.add_* function. tool, validator, guardrail, and hook are always refused — they map onto frozen roots (tools/verify.validators are code-execution roots); only skill is addable. |
POST /remove |
mutate |
construct.remove_item — refused for any ALWAYS_FROZEN root, whether named by dotted path or by an entry's builtin/code-ref name. |
POST /evolve/propose |
evolve |
Drafts and queues a proposal (trigger="http"); never applies. |
GET /proposals |
read |
List queued proposals for this harness (optional ?status=). |
GET /proposals/{id} |
read |
Show one proposal, bound to the served harness; 404 if unknown or if it belongs to a different harness. |
POST /proposals/{id}/apply |
evolve |
Apply a queued proposal (bound to the served harness). Body: {"approve_code": [...], "apply_yaml": bool} — the HTTP substitute for interactive y/n; anything not listed in approve_code stays pending. |
POST /proposals/{id}/reject |
evolve |
Reject a queued proposal, bound to the served harness. Body: {"reason": "..."}. |
POST /set's value is an already-typed JSON value (a number stays a
number), not a YAML-scalar string like the CLI's positional argument — JSON
already distinguishes types, so there's no ambiguity to resolve. POST /run
never supports the CLI's --file-style server-side path reads; see the
input-handling section below.
Frozen roots: what mutate scope can never reach
construct.set_value/add_*/remove_item are the sanctioned way to edit a
spec locally and deliberately ignore ALWAYS_FROZEN — that's the
evolver's boundary, not construct's. Reachable over HTTP, the same freedom
is a remote-configuration hole: setting model could repoint the executor
at an attacker-controlled endpoint (exfiltrating every prompt and tool
result), setting logging.redact could strip redaction so secrets land in
traces in cleartext, setting guardrails could remove the cost cap
entirely. So /set, every /add/{kind}, and /remove refuse any of
ALWAYS_FROZEN's roots — guardrails, model, logging.redact,
extensions, hooks, mcp_servers, and evolution.auto_propose — with
403, not 400: this is "your scope does not permit that," not "your request
was malformed." The local CLI is completely unaffected; this check lives
entirely in the HTTP layer (serve/app.py), derived from ALWAYS_FROZEN
itself rather than a hand-maintained parallel list, so it never drifts from
what the evolver already refuses to touch.
Three roots are frozen over HTTP beyond ALWAYS_FROZEN, because the
control plane's threat model is stricter than the evolver's (a remote token,
not a local operator): tools and verify.validators are code-execution
roots — add tool shell and the command_succeeds validator both run
arbitrary shell on the next run — and name binds every Hive lookup
(/trace, /proposals) to this harness, so rewriting it would re-point those
reads at another harness. The freeze is by containment, not just an exact
match: writing a parent of a frozen leaf (/set logging over the frozen
logging.redact, /set evolution over evolution.auto_propose) overwrites
the child, so ancestors of a frozen path are refused too. Adding a skill
(/add/skill) remains the one permitted add over HTTP.
Proposals and traces are bound to the served harness
The Hive (~/.hiveloom/hive.db) is global — every harness on the box
shares it. GET /trace/{run_id} and the /proposals/{id}... endpoints
look up by id, so without an explicit check a caller authorized for one
harness could read another harness's run traces, or read/reject/apply
another harness's proposals, just by guessing or enumerating an id. Every
such lookup is bound to the harness this process was started against;
anything belonging to a different harness comes back as a plain 404, not a
403 — the response never confirms that an id exists elsewhere.
Status-code mapping
| Condition | HTTP status |
|---|---|
A run completes — success, verify_failed, guardrail_halt, max_turns, or error are all valid results, not failures |
200 |
Bad input (SpecError, a malformed field) |
400 |
| Missing/malformed/expired/revoked bearer token | 401 |
Valid token, wrong scope, or a /set//add//remove targeting an ALWAYS_FROZEN root |
403 |
| Unknown id (run, proposal), or one that belongs to a different harness | 404 |
/run at max-concurrent-runs + max-queued-runs capacity |
503 + Retry-After |
| Anything unhandled | 500 |
Concurrency
/run is bounded by a small worker pool (hiveloom.serve.runslots.RunSlots):
--max-concurrent-runs (default 1) workers, plus up to
--max-queued-runs (default 4) more waiting before the endpoint starts
rejecting with 503. A harness runs one call at a time by default because a
run invokes a model and can be expensive; the queue absorbs a short burst
without ever growing unbounded. Every other endpoint (construct mutations,
evolve/propose, proposals, stats, trace) is cheap file/SQLite I/O dispatched
off the event loop without competing for run capacity.
A single threading.Lock (the "spec lock") serializes every mutating
endpoint's read-modify-write cycle on harness.yaml, closing a race between
concurrent /set//add//remove//proposals/{id}/apply calls. It is
not held for a run's duration: an in-flight run already loaded its own
spec snapshot before starting, so a concurrent mutation only ever affects
later runs — the same semantics as redeploying a harness folder underneath
a running process.
POST /run input handling (security)
The CLI's _resolve_input treats any existing file path as "read this
file" — fine for a trusted operator's own terminal, but arbitrary file read
on the server host if a network caller controlled that string. So over
HTTP:
inputis always literal text. A value that happens to name a real file on the server is sent to the model as that literal string, never read.- An optional, separate
input_filefield is resolved relative to the harness directory and rejected if it would escape it — and rejected if it points at anythingpackage.pyalready treats as "never leaves the harness":.hiveloom/(the trust store, construction log, and — for a served harness — its ownauthorized_keys.jsonand every prior run's trace),.env*(a deployed harness may hold live provider credentials such asANTHROPIC_API_KEYthere), or the configuredlogging.trace_direven when it's been moved outside.hiveloom/. Staying inside the harness directory is necessary but not sufficient; both checks share one definition (hiveloom.package.is_sensitive_path, matched case-insensitively since a caller's casing isn't corrected to the on-disk name on a case-insensitive filesystem) so packaging and serving can never disagree about what counts as sensitive. - This is the SAME protection the harness's own
file_read/file_writetools get, and the evolver's code-change containment: all four callers go through_safe_path, which now enforces the identical sensitivity check —.hiveloom/,.env*, and the configured trace directory (default or reconfigured) — for every one of them, not justinput_file. A harness withfile_readconfigured cannot read its own auth store,.env, or a reconfigured trace directory's contents either:runscope over HTTP never grants more filesystem reach than the model already had running locally.
Limitations (loud, on purpose)
- No TLS. Tokens travel in cleartext
Authorizationheaders on the wire. Put this behind a TLS-terminating proxy if the network isn't already trusted. - No replay/nonce cache. A captured token is replayable by anyone who
captures it, until it expires — hence the short 900-second (15 minute)
default TTL, and a hard 7-day ceiling (
keys.MAX_TTL_SECONDS) the deploy box enforces at verify time so no single captured token stays replayable indefinitely regardless of the--ttlits minter chose. - No revocation propagation beyond this one store. Within this store,
revocation IS immediate:
verify_bearerreadsauthorized_keys.jsonfresh on every request (no caching), so a revoked key's tokens are rejected starting with the very next request to this harness — no waiting for a TTL to expire. What does NOT propagate: if the same public key was separately authorized against a different harness (a differentauthorized_keys.json), revoking it here leaves that other copy untouched — there is no central registry linking them. - One harness per process.
hiveloom control-planeserves exactly the directory it was started against. Running several harnesses means several processes (and, if exposed beyond loopback, several ports). - Localhost by default, no built-in TLS. Binding elsewhere prints a loud stderr warning; there is no in-process TLS termination, rate limiting, mTLS, or HA — put a reverse proxy in front for any of that.
- Non-production by design. This trades away the hardening a real identity system and API gateway would have (mTLS, revocation lists, replay protection, key rotation ceremonies, rate limiting, audit logging beyond the kept-row trail) in exchange for something a single operator can stand up in minutes. Do not point it at anything where a captured token, an unrevoked key over its TTL window, or an exposed port is an acceptable-but-forgotten risk.