281 lines
11 KiB
Rust
281 lines
11 KiB
Rust
//! The [`Permission`] set an agent may hold.
|
|
//!
|
|
//! Lives in this leaf crate so every crate that names a permission, hive and
|
|
//! swarm side alike, can do so without depending on `hive-sh4re` (and through
|
|
//! it `hive-priv-sock`). The built-in tool resolution that consumes it is
|
|
//! `hive_sh4re::permissions`.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// One thing an agent may be permitted. Stored by its `snake_case` name.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Permission {
|
|
/// Grants no tool any more: agents message each other over matrix, and
|
|
/// the hyperhive `send` / `recv` / `ack_until` tools are gone. Kept as an
|
|
/// accepted value because it is stored across the swarm (permission sets,
|
|
/// imports, agent configs).
|
|
Messaging,
|
|
/// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`)
|
|
Meta,
|
|
/// `get_loose_ends`, `cancel_loose_end`, `remind`
|
|
Inbox,
|
|
/// Gates whether the `bash` MCP server (`mcp__bash__run`/`status`/
|
|
/// `kill`) is rendered into the agent's config at all — see
|
|
/// `extra_server_required_permission` in `hive-agent/src/mcp_config.rs`.
|
|
/// `tools()` returns `&[]`: this isn't a `mcp__hyperhive__*`
|
|
/// allowlist entry, the gate lives at config-render time instead.
|
|
Execution,
|
|
/// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and
|
|
/// `WebSearch` (search the web). Both are omitted from `--tools`
|
|
/// without it; holding it enables them in the session and in
|
|
/// `--allowedTools` so they run without a confirmation prompt.
|
|
WebTools,
|
|
/// Agent may manage *any* agent in the hive. Concretely, hive-c0re
|
|
/// bind-mounts every agent's state dir (RW) and config dir (RO) into the
|
|
/// holder's container, plus `/applied` and `/meta` read-only, so the
|
|
/// holder can recover an agent that is down. See
|
|
/// `lifecycle::host_config::set_nspawn_flags`.
|
|
///
|
|
/// The name is historical, from when "the root agent" was the widest
|
|
/// thing to manage. Kept under that spelling because every stored grant
|
|
/// carries it.
|
|
ManageRootAgent,
|
|
/// Read access to the log store: what `swarm-logs` queries. Checked by
|
|
/// the log gateway's `swarm-logs-auth` against the grant swarm-controller
|
|
/// mirrors into the secret store, so `tools()` is empty. Not in
|
|
/// [`Permission::DEFAULT`].
|
|
Logs,
|
|
/// Read access to the metrics store: what `swarm-metrics` queries.
|
|
/// Checked like [`Permission::Logs`], for the metrics gateway. Not in
|
|
/// [`Permission::DEFAULT`].
|
|
Metrics,
|
|
}
|
|
|
|
impl Permission {
|
|
/// Every permission in a stable order.
|
|
pub const ALL: &'static [Self] = &[
|
|
Self::Messaging,
|
|
Self::Meta,
|
|
Self::Inbox,
|
|
Self::Execution,
|
|
Self::WebTools,
|
|
Self::ManageRootAgent,
|
|
Self::Logs,
|
|
Self::Metrics,
|
|
];
|
|
|
|
/// What an agent the swarm sets nothing for holds, whatever its name.
|
|
pub const DEFAULT: &'static [Self] =
|
|
&[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution];
|
|
|
|
/// The MCP tool names (without the `mcp__hyperhive__` prefix) this
|
|
/// permission grants. Empty for the permissions that grant something
|
|
/// else; see each variant's doc comment.
|
|
#[must_use]
|
|
pub fn tools(self) -> &'static [&'static str] {
|
|
match self {
|
|
Self::Meta => &["get_agent_meta"],
|
|
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
|
|
// `Messaging` grants nothing now (agents message over matrix).
|
|
// `Execution` grants the out-of-process `bash` MCP server,
|
|
// gated at config-render time in `hive-agent/src/mcp_config.rs`
|
|
// — an out-of-process server has no later enforcement point, so
|
|
// that gate is the actual security boundary. `WebTools` grants
|
|
// Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants
|
|
// bind mounts; `Logs` and `Metrics` are checked by the store
|
|
// gateways, not by a tool allowlist.
|
|
Self::Messaging
|
|
| Self::Execution
|
|
| Self::WebTools
|
|
| Self::ManageRootAgent
|
|
| Self::Logs
|
|
| Self::Metrics => &[],
|
|
}
|
|
}
|
|
|
|
/// MCP tools that are always exposed regardless of which permissions an
|
|
/// agent holds. `set_status` lives here because the operator
|
|
/// dashboard depends on every agent being able to report its status
|
|
/// chip — gating it would let a misconfigured agent go dark on the
|
|
/// dashboard. The server-side `SetStatus` handler has no permission
|
|
/// 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, and every
|
|
/// agent should be able to reach for it — same reasoning as `set_status`.
|
|
///
|
|
/// `mark_todos_done` too: todos are pushed to an agent whether or not it
|
|
/// holds `Inbox` (which only gates `get_loose_ends`/`cancel_loose_end`/
|
|
/// `remind`), so an agent without `Inbox` must still be able to clear
|
|
/// them.
|
|
pub const ALWAYS_ON_TOOLS: &'static [&'static str] =
|
|
&["set_status", "compact", "mark_todos_done"];
|
|
|
|
/// The Claude built-in tool names this permission enables. Only
|
|
/// `WebTools` returns a non-empty slice.
|
|
#[must_use]
|
|
pub fn builtin_tools(self) -> &'static [&'static str] {
|
|
match self {
|
|
Self::WebTools => &["WebFetch", "WebSearch"],
|
|
_ => &[],
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Permission {
|
|
/// The stored `snake_case` name; identical to the serde form.
|
|
#[must_use]
|
|
pub const fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Messaging => "messaging",
|
|
Self::Meta => "meta",
|
|
Self::Inbox => "inbox",
|
|
Self::Execution => "execution",
|
|
Self::WebTools => "web_tools",
|
|
Self::ManageRootAgent => "manage_root_agent",
|
|
Self::Logs => "logs",
|
|
Self::Metrics => "metrics",
|
|
}
|
|
}
|
|
|
|
/// The permission stored under `name`; `None` for a name this build does
|
|
/// not know.
|
|
#[must_use]
|
|
pub fn from_name(name: &str) -> Option<Self> {
|
|
Self::ALL.iter().copied().find(|p| p.as_str() == name)
|
|
}
|
|
}
|
|
|
|
/// Decodes a stored permission list, skipping names this build does not know.
|
|
/// For `#[serde(deserialize_with)]` on a stored record: a permission a newer
|
|
/// swarm adds must not cost an older reader the rest of the record.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// When the value is not a list of strings.
|
|
pub fn deserialize_known<'de, D>(d: D) -> Result<Option<Vec<Permission>>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let names: Option<Vec<String>> = Option::deserialize(d)?;
|
|
Ok(names.map(|names| {
|
|
names
|
|
.iter()
|
|
.filter_map(|name| {
|
|
let known = Permission::from_name(name);
|
|
if known.is_none() {
|
|
tracing::debug!(name, "skipping a permission this build does not know");
|
|
}
|
|
known
|
|
})
|
|
.collect()
|
|
}))
|
|
}
|
|
|
|
impl From<Permission> for &'static str {
|
|
fn from(perm: Permission) -> Self {
|
|
perm.as_str()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Agents message each other over matrix, so no permission grants a
|
|
/// hyperhive messaging tool any more. `Messaging` stays a valid stored
|
|
/// value, and it grants nothing.
|
|
#[test]
|
|
fn messaging_is_an_accepted_value_that_grants_no_tool() {
|
|
assert!(Permission::Messaging.tools().is_empty());
|
|
assert_eq!(
|
|
serde_json::from_str::<Permission>("\"messaging\"").expect("stored value decodes"),
|
|
Permission::Messaging
|
|
);
|
|
for p in Permission::ALL {
|
|
for tool in p.tools() {
|
|
assert!(
|
|
!["send", "recv", "ack_until"].contains(tool),
|
|
"{p:?} still grants the removed tool {tool}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The `as_str` spelling of every variant. `ALL` is stable-ordered, so
|
|
/// a variant added to the enum but not to `ALL` or `as_str` shows here.
|
|
#[test]
|
|
fn as_str_pins_every_variants_name() {
|
|
let names: Vec<&str> = Permission::ALL.iter().map(|p| <&str>::from(*p)).collect();
|
|
assert_eq!(
|
|
names,
|
|
[
|
|
"messaging",
|
|
"meta",
|
|
"inbox",
|
|
"execution",
|
|
"web_tools",
|
|
"manage_root_agent",
|
|
"logs",
|
|
"metrics"
|
|
]
|
|
);
|
|
}
|
|
|
|
/// Reading stores a log or metric grant is never a default: an agent the
|
|
/// swarm sets nothing for does not hold them.
|
|
#[test]
|
|
fn logs_and_metrics_are_off_by_default() {
|
|
assert!(!Permission::DEFAULT.contains(&Permission::Logs));
|
|
assert!(!Permission::DEFAULT.contains(&Permission::Metrics));
|
|
assert!(Permission::ALL.contains(&Permission::Logs));
|
|
assert!(Permission::ALL.contains(&Permission::Metrics));
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct Record {
|
|
#[serde(default, deserialize_with = "deserialize_known")]
|
|
permissions: Option<Vec<Permission>>,
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_name_is_skipped_not_a_decode_failure() {
|
|
let r: Record =
|
|
serde_json::from_str(r#"{"permissions":["messaging","telepathy","logs"]}"#).unwrap();
|
|
assert_eq!(
|
|
r.permissions,
|
|
Some(vec![Permission::Messaging, Permission::Logs])
|
|
);
|
|
// Presence control: an unset field stays unset, `[]` stays empty.
|
|
let r: Record = serde_json::from_str("{}").unwrap();
|
|
assert_eq!(r.permissions, None);
|
|
let r: Record = serde_json::from_str(r#"{"permissions":[]}"#).unwrap();
|
|
assert_eq!(r.permissions, Some(vec![]));
|
|
// A list of non-strings is still a decode failure.
|
|
assert!(serde_json::from_str::<Record>(r#"{"permissions":[1]}"#).is_err());
|
|
}
|
|
|
|
/// The serde form of every variant, pinned independently of `as_str`:
|
|
/// stored grants decode by it, so it must stay byte-identical.
|
|
#[test]
|
|
fn serde_form_pins_every_variants_name() {
|
|
let want = [
|
|
(Permission::Messaging, "\"messaging\""),
|
|
(Permission::Meta, "\"meta\""),
|
|
(Permission::Inbox, "\"inbox\""),
|
|
(Permission::Execution, "\"execution\""),
|
|
(Permission::WebTools, "\"web_tools\""),
|
|
(Permission::ManageRootAgent, "\"manage_root_agent\""),
|
|
(Permission::Logs, "\"logs\""),
|
|
(Permission::Metrics, "\"metrics\""),
|
|
];
|
|
assert_eq!(want.len(), Permission::ALL.len());
|
|
for (perm, json) in want {
|
|
assert_eq!(serde_json::to_string(&perm).unwrap(), json);
|
|
assert_eq!(serde_json::from_str::<Permission>(json).unwrap(), perm);
|
|
}
|
|
assert!(serde_json::from_str::<Permission>("\"lifecycle\"").is_err());
|
|
}
|
|
}
|