The gateway's nginx + dnsmasq no longer run in their own nspawn container. `nix/host-modules/hive-gateway/default.nix` loses the `containers.hive-gateway` wrapper and everything that existed only to punch holes in it: `privateNetwork = false`, `CAP_NET_ADMIN`, five bind mounts, its own `stateVersion`, `networking.firewall.enable = false`, `networking.resolvconf.enable = false`, and the `hive-gateway-resolv` path+service pair. 465 -> 303 lines. The container never bought isolation here. It shared the host netns by necessity — nginx binds the host's :80/:443, dnsmasq answers on the bridge — so each of those settings was undoing a boundary the gateway could not afford in the first place. Four things made it more than a deletion, none of them visible in the nix diff: - The self-signed cert service also imports the hive CA leaf, so removing it with the container would have left nginx naming a missing cert file, which it refuses to load at all. - The nginx reload is a hive-priv verb. It still needs root, but no longer for the reason its doc gave, and `--machine=` was both transport and scope — so the unit name is now hard-coded in the helper as the containment. - The lifecycle verb named a container that stops existing. - `journalctl -M hive-gateway` had no machine to enter. Per the operator's ruling, the operator verb keeps working and agents lose it. `InfraContainer` answered three questions that used to share an answer; it now splits into `name()` (identity), `target()` (Container vs HostUnit), `service_unit()` (the systemd unit), and `agent_restartable()`, which the MCP restart path checks before the capability so the refusal cannot read as "ask for infra_admin". `SIBLING_CONTAINERS` drops the gateway — it gates the requests that name a container as a string — while `FromStr` still accepts it, because that answers what a name is, not who may act on it. The dashboard's gateway journal reads host journald filtered to `nginx.service`. Prose was corrected where it only named a location, and re-argued where the container was doing security work: a `0666` per-agent socket was safe because only the gateway container had the directory bind-mounted. There is no mount now, so the directory permissions are the whole of the access control — the constraint holds, its mechanism doesn't. Gate: nix fmt / clippy --all-targets -D warnings / cargo test all clean (710 tests); hivectl-cli.md regenerated from the clap tree. The nix eval was run in both TLS shapes at this commit: every delta in the rendered virtualHosts is one of the three intended path moves, dnsmasq settings are byte-identical, and the absence probe flips true -> false with bindMounts emptied.
278 lines
12 KiB
Rust
278 lines
12 KiB
Rust
//! Per-agent authorization: named MCP-tool groups (`ToolGroup`) and
|
|
//! system-level capability grants (`Capability`). Both are declared in
|
|
//! per-agent config (`tool-groups.json` / `capabilities.json`), injected
|
|
//! into the container as env vars, and read by the harness to decide
|
|
//! which MCP tools claude actually sees.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Named group of MCP tools an agent may be granted. The harness reads
|
|
/// `HIVE_TOOL_GROUPS` from the environment (a comma-separated list of
|
|
/// `snake_case` group names written by the meta renderer from per-agent
|
|
/// config) and expands it to the matching tool names for `--allowedTools`.
|
|
/// When the env var is absent the harness falls back to `AGENT_DEFAULT`.
|
|
/// See `docs/conventions.md::Tool groups`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ToolGroup {
|
|
/// `send`, `recv`, `ask`, `answer`
|
|
Messaging,
|
|
/// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`)
|
|
Meta,
|
|
/// `get_loose_ends`, `cancel_loose_end`, `remind`
|
|
Inbox,
|
|
/// `kill`, `start`, `restart`, `update` - *(privileged)*
|
|
Lifecycle,
|
|
/// `request_init_config`, `request_update_meta_inputs` - *(privileged)*
|
|
Approvals,
|
|
/// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`,
|
|
/// `edit_schedule`, `list_schedules` - *(privileged)*
|
|
Scheduling,
|
|
/// `get_logs` - *(privileged)*
|
|
Diagnostics,
|
|
/// `create_repo` — create git repos through hive-c0re (the only path
|
|
/// now that agents can't create them directly). Opt-in per
|
|
/// agent so the operator controls who can spin up repos.
|
|
Forge,
|
|
/// `run`, `status` (via `mcp__bash__*`)
|
|
Execution,
|
|
/// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and
|
|
/// `WebSearch` (search the web). Both are omitted from `--tools` by
|
|
/// default; adding this group to an agent enables them in the session
|
|
/// and in `--allowedTools` so they run without a confirmation prompt.
|
|
/// Does not gate any MCP tools — `tools()` returns `&[]`.
|
|
WebTools,
|
|
}
|
|
|
|
impl ToolGroup {
|
|
/// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group.
|
|
/// Returns `&[]` for `WebTools` — it enables Claude built-in tools,
|
|
/// not MCP tools; see `builtin_tools()`.
|
|
#[must_use]
|
|
pub fn tools(self) -> &'static [&'static str] {
|
|
match self {
|
|
Self::Messaging => &["send", "recv", "ack_until", "ask", "answer"],
|
|
Self::Meta => &["get_agent_meta"],
|
|
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
|
|
Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"],
|
|
Self::Approvals => &["request_init_config", "request_update_meta_inputs"],
|
|
Self::Scheduling => &[
|
|
"request_schedule_prompt",
|
|
"fire_schedule_now",
|
|
"cancel_schedule",
|
|
"edit_schedule",
|
|
"list_schedules",
|
|
],
|
|
Self::Diagnostics => &["get_logs"],
|
|
Self::Forge => &["create_repo"],
|
|
Self::Execution => &["run", "status"],
|
|
Self::WebTools => &[],
|
|
}
|
|
}
|
|
|
|
/// MCP tools that are always exposed regardless of which tool groups an
|
|
/// agent is granted. `set_status` lives here because the operator
|
|
/// dashboard depends on every agent being able to report its status
|
|
/// chip — gating it behind a group would let a misconfigured agent go
|
|
/// dark on the dashboard. The server-side `SetStatus` handler has no
|
|
/// tool-group check either (only length validation), so listing it here
|
|
/// keeps the `--allowedTools` list honest with that reality.
|
|
///
|
|
/// `compact` lives here too: it's pure self-management (no cross-agent
|
|
/// effect, no privilege), gated server-side on context usage rather
|
|
/// than on tool groups, and every agent should be able to reach for it
|
|
/// regardless of which optional groups it's been granted — same
|
|
/// reasoning as `set_status`.
|
|
///
|
|
/// `mark_todos_done` too (a critical bug every agent hit): it was
|
|
/// declared as a `#[tool]` fn but never added to *any*
|
|
/// group's [`tools`](Self::tools), including `Inbox`, so no agent could
|
|
/// ever get it into `--allowedTools` and every call prompted for
|
|
/// approval it can't get. Todos are pushed to an agent independent of
|
|
/// whether it holds `Inbox` (that group only gates
|
|
/// `get_loose_ends`/`cancel_loose_end`/`remind`), so an agent without
|
|
/// `Inbox` could accumulate todos it can never clear — same
|
|
/// "every agent needs this regardless of optional groups" shape as
|
|
/// `set_status`/`compact`, not a narrower `Inbox`-only fix.
|
|
pub const ALWAYS_ON_TOOLS: &'static [&'static str] =
|
|
&["set_status", "compact", "mark_todos_done"];
|
|
|
|
/// The Claude built-in tool names enabled by this group. Only
|
|
/// `WebTools` returns a non-empty slice; all other groups return `&[]`
|
|
/// (they control MCP tools via `tools()` instead).
|
|
#[must_use]
|
|
pub fn builtin_tools(self) -> &'static [&'static str] {
|
|
match self {
|
|
Self::WebTools => &["WebFetch", "WebSearch"],
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
/// Default tool groups for an agent harness. Used when `HIVE_TOOL_GROUPS` is unset.
|
|
pub const AGENT_DEFAULT: &'static [Self] =
|
|
&[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution];
|
|
|
|
/// Convenience preset for a fully-privileged agent (all groups).
|
|
/// Use this as a starting point in `tool-groups.json` for root/manager agents.
|
|
pub const MANAGER_DEFAULT: &'static [Self] = &[
|
|
Self::Messaging,
|
|
Self::Meta,
|
|
Self::Inbox,
|
|
Self::Lifecycle,
|
|
Self::Approvals,
|
|
Self::Scheduling,
|
|
Self::Diagnostics,
|
|
Self::Execution,
|
|
];
|
|
|
|
/// Every known tool group in a stable order. Use this to enumerate
|
|
/// columns in the capabilities UI or any other place that needs the
|
|
/// full list without hard-coding it at the call site.
|
|
pub const ALL: &'static [Self] = &[
|
|
Self::Messaging,
|
|
Self::Meta,
|
|
Self::Inbox,
|
|
Self::Lifecycle,
|
|
Self::Approvals,
|
|
Self::Scheduling,
|
|
Self::Diagnostics,
|
|
Self::Forge,
|
|
Self::Execution,
|
|
Self::WebTools,
|
|
];
|
|
|
|
/// The `snake_case` wire name for this group (matches `serde(rename_all =
|
|
/// "snake_case")` serialisation).
|
|
#[must_use]
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Messaging => "messaging",
|
|
Self::Meta => "meta",
|
|
Self::Inbox => "inbox",
|
|
Self::Lifecycle => "lifecycle",
|
|
Self::Approvals => "approvals",
|
|
Self::Scheduling => "scheduling",
|
|
Self::Diagnostics => "diagnostics",
|
|
Self::Forge => "forge",
|
|
Self::Execution => "execution",
|
|
Self::WebTools => "web_tools",
|
|
}
|
|
}
|
|
|
|
/// Short human-readable description suitable for a tooltip or help text.
|
|
#[must_use]
|
|
pub fn description(self) -> &'static str {
|
|
match self {
|
|
Self::Messaging => "send, recv, ask, answer — core agent communication",
|
|
Self::Meta => {
|
|
"get_agent_meta — identity introspection (set_status is always available)"
|
|
}
|
|
Self::Inbox => "get_loose_ends, cancel_loose_end, remind — self-scheduling",
|
|
Self::Lifecycle => {
|
|
"kill, start, restart, update, list_containers — container lifecycle (privileged)"
|
|
}
|
|
Self::Approvals => {
|
|
"request_init_config, request_update_meta_inputs — config change flow (privileged)"
|
|
}
|
|
Self::Scheduling => {
|
|
"request_schedule_prompt and related — operator-visible scheduled prompts (privileged)"
|
|
}
|
|
Self::Diagnostics => {
|
|
"get_logs — read a sub-agent container's systemd journal (privileged)"
|
|
}
|
|
Self::Forge => {
|
|
"create_repo — create git repos through hive-c0re (operator-gated merge)"
|
|
}
|
|
Self::Execution => {
|
|
"run, status — run shell commands via mcp__bash__run / mcp__bash__status"
|
|
}
|
|
Self::WebTools => "WebFetch, WebSearch — Claude built-in web egress; not MCP tools",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Per-agent capability grants. Stored in `meta/capabilities.json`
|
|
/// (same shape as `tool-groups.json`: `{ "alice": ["read_host_journal"] }`).
|
|
/// Capabilities control system-level access hive-c0re enforces at
|
|
/// dispatch time; they are orthogonal to tool groups (which control
|
|
/// which MCP tools the harness exposes to claude).
|
|
///
|
|
/// Injected into containers as `HIVE_CAPABILITIES` (comma-separated
|
|
/// `snake_case`) via `meta::render_flake`. The harness reads this to
|
|
/// conditionally register capability-gated MCP tools so claude only
|
|
/// sees tools it can actually invoke. See `docs/conventions.md::Capabilities`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Capability {
|
|
/// Agent can lifecycle-manage the root agent (kill/start/restart)
|
|
/// on behalf of the hive when the root has crashed. Named capability
|
|
/// for the existing manager privilege — future topology enforcement
|
|
/// will gate this via the capability system instead of the hardcoded
|
|
/// `container == MANAGER_CONTAINER` check.
|
|
ManageRootAgent,
|
|
/// Agent can read the full host journal via `GetHostJournal`.
|
|
/// hive-c0re checks this capability before running journalctl.
|
|
/// MCP tool `get_host_journal` is only registered in the harness
|
|
/// when this capability is present.
|
|
ReadHostJournal,
|
|
/// Agent can query non-child agents via `GetLooseEnds`,
|
|
/// `CountPendingReminders`, and `ReminderRollup` on the agent
|
|
/// socket. Without this capability, targeting a non-child agent is
|
|
/// rejected with an error (direct children are always accessible
|
|
/// without any capability). The `"*"` hive-wide value is not
|
|
/// available on the agent socket even with this capability — use the
|
|
/// manager socket for swarm-wide scans.
|
|
QueryAgentState,
|
|
/// Agent can restart hive infrastructure containers (hive-ci,
|
|
/// hive-forge, hive-matrix) via the `restart` MCP tool. hive-c0re
|
|
/// checks this capability before routing the restart through
|
|
/// hive-priv; the concrete service allowlist lives root-side in
|
|
/// hive-priv. Deliberately generic ("infra admin") so future
|
|
/// privileged infra ops can hang off the same grant.
|
|
///
|
|
/// ⚠️ The gateway is **not** in reach of this capability, by operator
|
|
/// ruling — it is the host's nginx and fronts the forge, dashboard and
|
|
/// matrix, so an agent restarting it can cut the path its own fix
|
|
/// travels. That refusal is a property of the target, not of the
|
|
/// grant: no capability re-opens it.
|
|
InfraAdmin,
|
|
}
|
|
|
|
impl Capability {
|
|
/// Every known capability in a stable order. Use this to enumerate
|
|
/// columns in the permissions UI or validate incoming capability strings.
|
|
pub const ALL: &'static [Self] = &[
|
|
Self::ManageRootAgent,
|
|
Self::ReadHostJournal,
|
|
Self::QueryAgentState,
|
|
Self::InfraAdmin,
|
|
];
|
|
|
|
/// Canonical `snake_case` name for this capability (matches serde).
|
|
#[must_use]
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::ManageRootAgent => "manage_root_agent",
|
|
Self::ReadHostJournal => "read_host_journal",
|
|
Self::QueryAgentState => "query_agent_state",
|
|
Self::InfraAdmin => "infra_admin",
|
|
}
|
|
}
|
|
|
|
/// Short human-readable description suitable for a tooltip or help text.
|
|
#[must_use]
|
|
pub fn description(self) -> &'static str {
|
|
match self {
|
|
Self::ManageRootAgent => {
|
|
"lifecycle-manage the root/manager agent on hive crash recovery"
|
|
}
|
|
Self::ReadHostJournal => "read host journald via get_host_journal MCP tool",
|
|
Self::QueryAgentState => {
|
|
"query non-child agents' loose ends and reminder state via get_loose_ends"
|
|
}
|
|
Self::InfraAdmin => {
|
|
"restart hive infrastructure containers (hive-ci, hive-forge, hive-matrix; not the gateway) via the restart tool"
|
|
}
|
|
}
|
|
}
|
|
}
|