diff --git a/Cargo.lock b/Cargo.lock index f6252caa..8d1f634d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1675,7 +1675,6 @@ dependencies = [ "hive-runtime", "hive-sh4re", "hive-sock-client", - "hive-types", "http-body-util", "hyper", "hyper-util", @@ -2025,6 +2024,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "strum", ] [[package]] @@ -4869,7 +4869,6 @@ dependencies = [ "backon", "base64", "bytes", - "chrono", "forgejo-api", "futures-util", "getrandom 0.3.4", @@ -4877,6 +4876,7 @@ dependencies = [ "hive-jobq-metrics", "hive-jobq-wire", "hive-log", + "hive-sh4re", "hive-types", "hmac 0.13.0", "http", @@ -5031,7 +5031,7 @@ dependencies = [ "async-nats", "axum", "data-encoding", - "hive-types", + "hive-sh4re", "reqwest", "serde", "serde_json", diff --git a/docs/process/conventions.md b/docs/process/conventions.md index d868c6b9..347131ba 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -272,7 +272,7 @@ JSON-line protocol with their own result types; RFC 9457 is an HTTP format. ## Permissions -One set of named `Permission` values (`hive_types::Permission`) +One set of named `Permission` values (`hive_sh4re::permissions::Permission`) decides what an agent may do: which MCP and built-in tools it gets, and which host access it has. @@ -342,7 +342,7 @@ agent grant itself any tool by submitting a config commit, bypassing the operator gate. **Adding to the set** — add a variant to `Permission` in -`hive-types/src/permission.rs` and to `Permission::ALL`, and enforce it where +`hive-sh4re/src/permissions.rs` and to `Permission::ALL`, and enforce it where it applies. A new `#[tool]` fn on `AgentServer` (`hive-agent-mcp/src/mcp/mod.rs`) goes into the matching `Permission::tools()` slice: that's the single source of truth `mcp_config::allowed_mcp_tools` (`hive-agent/src/mcp_config.rs`) diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index 37dcc503..67853254 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -197,8 +197,7 @@ status hint lives in the wake prompt + UI header, not here. ## Tool allowlist (`hive_sh4re::permissions::ALLOWED_BUILTIN_TOOLS`) The built-in list and the `--tools` value it resolves to live in -`hive-sh4re` (the `Permission` enum itself is in `hive-types`), not in the -harness, because the +`hive-sh4re` alongside `Permission`, not in the harness, because the subagent daemon spawns its own `claude` and must resolve the same set — see [`docs/tools/subagent.md`](../tools/subagent.md). diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 3342d99a..11e27d9c 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -135,7 +135,7 @@ impl AgentServer { } // IMPORTANT: when adding a new `#[tool]` fn to this impl, also add -// its name to the matching `Permission::tools()` slice in hive-types. +// its name to the matching `Permission::tools()` slice in hive-sh4re. // Claude Code's permission gate refuses uninlisted MCP tools in // non-interactive `--print` mode with "permissions not granted yet". #[tool_router] diff --git a/hive-agent/Cargo.toml b/hive-agent/Cargo.toml index 6a64d302..bcbc2ddc 100644 --- a/hive-agent/Cargo.toml +++ b/hive-agent/Cargo.toml @@ -29,7 +29,6 @@ hive-core-agent-sock.workspace = true hive-log.workspace = true hive-sh4re.workspace = true hive-sock-client.workspace = true -hive-types.workspace = true libc.workspace = true opentelemetry.workspace = true opentelemetry_sdk.workspace = true diff --git a/hive-agent/src/mcp_config.rs b/hive-agent/src/mcp_config.rs index 6429f2b0..c57e5b06 100644 --- a/hive-agent/src/mcp_config.rs +++ b/hive-agent/src/mcp_config.rs @@ -6,7 +6,7 @@ //! running MCP server (a separate binary) — the `send` allow-list check that //! server enforces lives alongside it in the `hive-agent-mcp` crate. -use hive_types::Permission; +use hive_sh4re::permissions::Permission; /// Name of the hyperhive MCP server inside claude's view. Claude prefixes /// tools as `mcp____` (e.g. `mcp__hyperhive__send`). @@ -55,7 +55,7 @@ fn extra_server_enabled(server: &str, perms: &[Permission]) -> bool { #[cfg(test)] mod extra_server_gate_tests { use super::{extra_server_enabled, extra_server_required_permission}; - use hive_types::Permission; + use hive_sh4re::permissions::Permission; #[test] fn bash_is_gated_behind_execution() { @@ -84,7 +84,7 @@ mod extra_server_gate_tests { /// MCP tools claude is allowed to call without prompting, derived from /// the supplied permissions. Adding a new `#[tool]` fn to a server impl -/// requires updating the matching `Permission::tools()` slice in hive-types +/// requires updating the matching `Permission::tools()` slice in hive-sh4re /// (single source of truth). #[must_use] pub fn allowed_mcp_tools(perms: &[Permission]) -> Vec { @@ -212,7 +212,7 @@ fn build_mcp_servers() -> serde_json::Map { #[cfg(test)] mod tests { use super::{SERVER_NAME, allowed_mcp_tools}; - use hive_types::Permission; + use hive_sh4re::permissions::Permission; fn qualified(tool: &str) -> String { format!("mcp__{SERVER_NAME}__{tool}") diff --git a/hive-agent/src/swarm_permissions.rs b/hive-agent/src/swarm_permissions.rs index 62b46faa..2e869543 100644 --- a/hive-agent/src/swarm_permissions.rs +++ b/hive-agent/src/swarm_permissions.rs @@ -17,7 +17,7 @@ use std::path::Path; use std::sync::{PoisonError, RwLock}; use std::time::Duration; -use hive_types::Permission; +use hive_sh4re::permissions::Permission; use swarm_queue_client::agent_permissions::{HIVE_COPY_PATH, HiveCopy, Read, read}; /// How often the key is read. Also the longest a change can take to reach diff --git a/hive-agent/src/term_msg.rs b/hive-agent/src/term_msg.rs index d3db1179..55553c95 100644 --- a/hive-agent/src/term_msg.rs +++ b/hive-agent/src/term_msg.rs @@ -11,7 +11,7 @@ //! both a text block and a `tool_use` block produces two rows. pub use hive_sh4re::term_msg::{ClassifyCtx, Level, TermMsg, iso8601_utc}; -use hive_types::term_msg::{TURN_ICON, TURN_SUMMARY_PREFIX}; +use hive_sh4re::term_msg::{TURN_ICON, TURN_SUMMARY_PREFIX}; use crate::events::LiveEvent; diff --git a/hive-agent/src/turn.rs b/hive-agent/src/turn.rs index e5dfd237..f0dff1f9 100644 --- a/hive-agent/src/turn.rs +++ b/hive-agent/src/turn.rs @@ -351,7 +351,8 @@ pub fn make_session(bus: &Bus) -> Result { /// against the agent's permissions at the moment it asks. fn acp_permission_policy() -> PermissionPolicy { std::sync::Arc::new(|ask: &PermissionAsk<'_>| { - let web = crate::swarm_permissions::current().contains(&hive_types::Permission::WebTools); + let web = crate::swarm_permissions::current() + .contains(&hive_sh4re::permissions::Permission::WebTools); acp_permits(ask, web) }) } diff --git a/hive-c0re/src/agent_config/permissions.rs b/hive-c0re/src/agent_config/permissions.rs index f0649c5b..1b09fe04 100644 --- a/hive-c0re/src/agent_config/permissions.rs +++ b/hive-c0re/src/agent_config/permissions.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use hive_types::Permission; +use hive_sh4re::permissions::Permission; use swarm_queue_client::agent_permissions::{AgentPermissions, HIVE_COPY_FILE, HiveCopy}; const FILE: &str = "swarm-agent-permissions.json"; diff --git a/hive-c0re/src/agent_config/topology.rs b/hive-c0re/src/agent_config/topology.rs index d5104fc8..e2374596 100644 --- a/hive-c0re/src/agent_config/topology.rs +++ b/hive-c0re/src/agent_config/topology.rs @@ -9,7 +9,7 @@ //! dashboard's tree view. All of that is gone; what the file is //! *for* now is the one thing that survived the removal — naming every //! agent, which is the set a -//! [`hive_types::Permission::ManageRootAgent`] holder gets +//! [`hive_sh4re::permissions::Permission::ManageRootAgent`] holder gets //! bind-mounted ([`all_agents`]). //! //! Broader-than-your-own bind-mount grants are **not** stored here: they @@ -65,7 +65,7 @@ fn read_from(path: &Path) -> std::io::Result> { } /// Every agent the roster knows about, in name order. This is the set -/// a [`hive_types::Permission::ManageRootAgent`] holder gets +/// a [`hive_sh4re::permissions::Permission::ManageRootAgent`] holder gets /// bind-mounted, and it is deliberately unfiltered: that permission means /// "may manage any agent", so the set is all of them. /// diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 4204ede5..b11d715d 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use hive_priv_sock::{BindMount, CredentialMount}; -use hive_types::Permission; +use hive_sh4re::permissions::Permission; use crate::coordinator::{AgentPaths, HiveEnv}; diff --git a/hive-c0re/src/workers/permissions_import.rs b/hive-c0re/src/workers/permissions_import.rs index e139d2a1..3b0dca89 100644 --- a/hive-c0re/src/workers/permissions_import.rs +++ b/hive-c0re/src/workers/permissions_import.rs @@ -13,7 +13,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use hive_types::Permission; +use hive_sh4re::permissions::Permission; use swarm_queue_client::agent_permissions::{ self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply, }; @@ -240,7 +240,7 @@ async fn send_at(client: &async_nats::Client, hive: &str, meta: &Path) -> Result #[cfg(test)] mod tests { use super::{FileMap, grants, seed_local_at}; - use hive_types::Permission::{self, *}; + use hive_sh4re::permissions::Permission::{self, *}; fn file(json: &str) -> FileMap { serde_json::from_str(json).expect("decodes") diff --git a/hive-c0re/src/workers/swarm_permissions.rs b/hive-c0re/src/workers/swarm_permissions.rs index 9ed88432..52955684 100644 --- a/hive-c0re/src/workers/swarm_permissions.rs +++ b/hive-c0re/src/workers/swarm_permissions.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; -use hive_types::Permission; +use hive_sh4re::permissions::Permission; use swarm_queue_client::agent_permissions::{self, AgentPermissions, Read}; use crate::coordinator::Coordinator; @@ -216,8 +216,12 @@ mod tests { assert!(changed.is_empty()); assert!(crate::permissions::read_at(&path).expect("read").is_empty()); assert!( - !crate::permissions::holds(&path, "ruth", hive_types::Permission::ManageRootAgent) - .expect("read") + !crate::permissions::holds( + &path, + "ruth", + hive_sh4re::permissions::Permission::ManageRootAgent + ) + .expect("read") ); } diff --git a/hive-sh4re/Cargo.toml b/hive-sh4re/Cargo.toml index da9d4744..0ade61b6 100644 --- a/hive-sh4re/Cargo.toml +++ b/hive-sh4re/Cargo.toml @@ -15,3 +15,4 @@ schemars.workspace = true serde.workspace = true # `stream_enrich` walks raw claude `stream-json` values into `term_msg` rows. serde_json.workspace = true +strum.workspace = true diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index ab125c24..b309f86a 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -18,7 +18,7 @@ //! server instead); the permission transfers to a subagent, not the //! mechanism. -use hive_types::Permission; +use serde::{Deserialize, Serialize}; /// Built-in claude tools present in every session regardless of permissions. /// Anything not in this list (and not added by a permission's @@ -90,10 +90,131 @@ pub fn subagent_builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> { tools } +/// One thing an agent may be permitted. Stored by its `snake_case` name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum Permission { + /// `send`, `recv`, `ack_until` + 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, +} + +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, + ]; + + /// 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::Messaging => &["send", "recv", "ack_until"], + Self::Meta => &["get_agent_meta"], + Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"], + // `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. + Self::Execution | Self::WebTools | Self::ManageRootAgent => &[], + } + } + + /// 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"], + _ => &[], + } + } +} + #[cfg(test)] mod tests { use super::*; + /// The stored names are the `snake_case` spellings the old tool-group + /// and capability files used, so an imported grant decodes. + #[test] + fn stored_names_are_snake_case() { + 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" + ] + ); + for perm in Permission::ALL { + let json = serde_json::to_string(perm).unwrap(); + assert_eq!(json, format!("\"{}\"", <&str>::from(*perm))); + } + assert!(serde_json::from_str::("\"lifecycle\"").is_err()); + } + /// Adding permissions only ever adds built-ins — the base set is in every /// resolution, and no permission can remove one. Both `--tools` callers (the /// harness and the subagent daemon) rely on this to reason about "the diff --git a/hive-sh4re/src/stream_enrich.rs b/hive-sh4re/src/stream_enrich.rs index 05beff1e..67ac545d 100644 --- a/hive-sh4re/src/stream_enrich.rs +++ b/hive-sh4re/src/stream_enrich.rs @@ -15,8 +15,7 @@ //! `mcp__hyperhive__send`'s row say, which tools get an expandable body) //! didn't change; only the shape it gets packed into did. -use crate::term_msg::{BodyFormat, ClassifyCtx, Level, TermMsg}; -use hive_types::term_msg::{SEND_ICON, SEND_SUMMARY_PREFIX}; +use crate::term_msg::{BodyFormat, ClassifyCtx, Level, SEND_ICON, SEND_SUMMARY_PREFIX, TermMsg}; use serde_json::{Value, json}; /// Classify one raw claude stream-json line into zero or more terminal rows. @@ -1347,16 +1346,13 @@ mod tests { })); assert_eq!(row.icon.as_deref(), Some(SEND_ICON)); assert_eq!(row.summary.contains(" · 3L"), lines, "{}", row.summary); - assert_eq!( - hive_types::term_msg::send_recipient(&row.summary), - Some("iris") - ); + assert_eq!(crate::term_msg::send_recipient(&row.summary), Some("iris")); } - assert_eq!(hive_types::term_msg::send_recipient("send* →"), None); - assert_eq!(hive_types::term_msg::send_recipient("Read(x)"), None); + assert_eq!(crate::term_msg::send_recipient("send* →"), None); + assert_eq!(crate::term_msg::send_recipient("Read(x)"), None); // A suffix lookalike in the name is not stripped. assert_eq!( - hive_types::term_msg::send_recipient("send* → a · xL"), + crate::term_msg::send_recipient("send* → a · xL"), Some("a · xL") ); } diff --git a/hive-sh4re/src/term_msg.rs b/hive-sh4re/src/term_msg.rs index d896934c..429e45be 100644 --- a/hive-sh4re/src/term_msg.rs +++ b/hive-sh4re/src/term_msg.rs @@ -128,6 +128,41 @@ pub fn iso8601_utc(unix_seconds: i64) -> String { .to_rfc3339_opts(SecondsFormat::Secs, true) } +/// Icon of the row an agent's `send` tool call makes. +pub const SEND_ICON: &str = "📤"; + +/// What a `send` row's summary starts with; the recipient follows. A +/// multi-line body appends ` · L`, which [`send_recipient`] drops. +pub const SEND_SUMMARY_PREFIX: &str = "send* → "; + +/// Icon of the row a turn started by a message makes. +pub const TURN_ICON: &str = "◆"; + +/// What a turn-start row's summary starts with; the sender follows. +pub const TURN_SUMMARY_PREFIX: &str = "TURN ← "; + +/// The recipient a `send` row's summary names, without the line-count suffix. +#[must_use] +pub fn send_recipient(summary: &str) -> Option<&str> { + let rest = summary.strip_prefix(SEND_SUMMARY_PREFIX)?; + Some(match rest.rsplit_once(" · ") { + Some((to, lines)) + if lines + .strip_suffix('L') + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) => + { + to + } + _ => rest, + }) +} + +/// The sender a turn-start row's summary names. +#[must_use] +pub fn turn_sender(summary: &str) -> Option<&str> { + summary.strip_prefix(TURN_SUMMARY_PREFIX) +} + /// Per-connection/per-request classification state. A live SSE stream keeps /// one of these alive for the connection's lifetime — `tool_use` id → name /// correlation, so a `tool_result` can tell it's answering a `recv` call and diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index 46319197..912a9015 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -98,7 +98,7 @@ use hive_claude::{Attach, Claude, Config, SessionStore}; use hive_runtime::{ AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop, }; -use hive_types::Permission; +use hive_sh4re::permissions::Permission; use swarm_wire::agent_inference::AgentInference; use tokio::sync::oneshot; diff --git a/hive-subagent-mcp/src/swarm_models.rs b/hive-subagent-mcp/src/swarm_models.rs index b2c41e55..c28356f5 100644 --- a/hive-subagent-mcp/src/swarm_models.rs +++ b/hive-subagent-mcp/src/swarm_models.rs @@ -26,7 +26,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; use std::time::Duration; -use hive_types::Permission; +use hive_sh4re::permissions::Permission; use swarm_queue_client::agent_inference::{self, read_own}; use swarm_queue_client::agent_permissions::{self, HIVE_COPY_PATH, HiveCopy}; use swarm_wire::agent_inference::AgentInference; diff --git a/hive-types/README.md b/hive-types/README.md index 9a057917..cc942401 100644 --- a/hive-types/README.md +++ b/hive-types/README.md @@ -8,15 +8,10 @@ Foundational shared **newtypes** for the hyperhive workspace. A zero-dependency ## What's here -- **`Permission`** — the set of named permissions an agent may hold, with its - stored `snake_case` names and the MCP and built-in tools each grants. - **`Ident`** — a validated agent-name newtype. Typing agent-name fields as `Ident` (rather than bare `String`) gets serde-validated parsing **at the socket boundary for free**: a malformed name is rejected at deserialize time instead of flowing into a handler that has to re-validate by hand. -- **`term_msg`** — the icons and summary prefixes of a terminal row that sent a - message or started a turn from one, and the parsers that read the recipient - or sender back out. The crate is deliberately a leaf with the smallest possible dependency footprint so anything in the workspace can depend on it. See `docs/process/conventions.md` diff --git a/hive-types/src/lib.rs b/hive-types/src/lib.rs index 0ef2e23d..961e557d 100644 --- a/hive-types/src/lib.rs +++ b/hive-types/src/lib.rs @@ -1,5 +1,4 @@ -//! Foundational shared types for the hyperhive workspace: [`Ident`], [`Permission`] -//! and the [`term_msg`] row markers. +//! Foundational shared newtypes for the hyperhive workspace. //! //! A zero-dependency (bar `serde`) leaf crate so every wire-type crate //! (`hive-sh4re`, `hive-host-sock`, `hive-core-agent-sock`) and both binaries @@ -7,12 +6,6 @@ //! and get serde-validated parsing at the socket boundary for free — with //! no cross-crate coupling and without growing `hive-sh4re`. -mod permission; - -pub use permission::Permission; - -pub mod term_msg; - /// The environment variable nix uses to hand this process the blacklist of /// names that already mean something to the message layer. /// diff --git a/hive-types/src/permission.rs b/hive-types/src/permission.rs deleted file mode 100644 index 83395480..00000000 --- a/hive-types/src/permission.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! 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 { - /// `send`, `recv`, `ack_until` - 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, -} - -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, - ]; - - /// 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::Messaging => &["send", "recv", "ack_until"], - Self::Meta => &["get_agent_meta"], - Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"], - // `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. - Self::Execution | Self::WebTools | Self::ManageRootAgent => &[], - } - } - - /// 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", - } - } -} - -impl From for &'static str { - fn from(perm: Permission) -> Self { - perm.as_str() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// 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" - ] - ); - } - - /// 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\""), - ]; - 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::(json).unwrap(), perm); - } - assert!(serde_json::from_str::("\"lifecycle\"").is_err()); - } -} diff --git a/hive-types/src/term_msg.rs b/hive-types/src/term_msg.rs deleted file mode 100644 index 4a4bf6e6..00000000 --- a/hive-types/src/term_msg.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! The icons and summary prefixes that mark a terminal row as a sent message -//! or a message-started turn, and the parsers that read the peer back out. -//! -//! The classifier in `hive-sh4re` builds the summaries and -//! `swarm-controller`'s message stream parses them, so both sides name the -//! same constants and the same parsers rather than each spelling the format. - -/// Icon of the row an agent's `send` tool call makes. -pub const SEND_ICON: &str = "📤"; - -/// What a `send` row's summary starts with; the recipient follows. A -/// multi-line body appends ` · L`, which [`send_recipient`] drops. -pub const SEND_SUMMARY_PREFIX: &str = "send* → "; - -/// Icon of the row a turn started by a message makes. -pub const TURN_ICON: &str = "◆"; - -/// What a turn-start row's summary starts with; the sender follows. -pub const TURN_SUMMARY_PREFIX: &str = "TURN ← "; - -/// The recipient a `send` row's summary names, without the line-count suffix. -#[must_use] -pub fn send_recipient(summary: &str) -> Option<&str> { - let rest = summary.strip_prefix(SEND_SUMMARY_PREFIX)?; - Some(match rest.rsplit_once(" · ") { - Some((to, lines)) - if lines - .strip_suffix('L') - .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) => - { - to - } - _ => rest, - }) -} - -/// The sender a turn-start row's summary names. -#[must_use] -pub fn turn_sender(summary: &str) -> Option<&str> { - summary.strip_prefix(TURN_SUMMARY_PREFIX) -} - -#[cfg(test)] -mod tests { - use super::{SEND_SUMMARY_PREFIX, TURN_SUMMARY_PREFIX, send_recipient, turn_sender}; - - #[test] - fn a_send_summary_names_its_recipient_with_and_without_the_line_count() { - assert_eq!( - send_recipient(&format!("{SEND_SUMMARY_PREFIX}iris")), - Some("iris") - ); - assert_eq!( - send_recipient(&format!("{SEND_SUMMARY_PREFIX}iris · 3L")), - Some("iris") - ); - assert_eq!(send_recipient("send* →"), None); - assert_eq!(send_recipient("Read(x)"), None); - // A suffix lookalike in the name is not stripped. - assert_eq!(send_recipient("send* → a · xL"), Some("a · xL")); - } - - #[test] - fn a_turn_summary_names_its_sender() { - assert_eq!( - turn_sender(&format!("{TURN_SUMMARY_PREFIX}atlas")), - Some("atlas") - ); - assert_eq!(turn_sender("TURN ←"), None); - assert_eq!(turn_sender("send* → iris"), None); - } -} diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index cb153ca8..04e8a644 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -27,8 +27,6 @@ forgejo-api.workspace = true # decodes blobs it reads: forgejo's content and blob APIs carry base64, # never raw bytes. base64.workspace = true -# `message_stream.rs` renders the history cutoff in the spelling of a row's `ts`. -chrono.workspace = true # `agent_identity.rs` mints the per-agent queue secret, the one value in this # tree this daemon invents rather than receives. Straight from the kernel's # CSPRNG — see the workspace entry for why this and not `rand`. @@ -71,8 +69,7 @@ sha2.workspace = true similar.workspace = true # The `Permission` names `/api/agents/{name}/permissions` accepts, the same # enum the hive enforces. -# The send and turn row markers `message_stream.rs` reads a message back out -# of. +hive-sh4re.workspace = true # Validates `POST /api/agents`' `name` before it becomes `agent`/`repo` # everywhere downstream — see `create_agent`'s doc comment for why this is # defense-in-depth, not the only gate (per an argus review finding). diff --git a/swarm-controller/src/agent_permissions.rs b/swarm-controller/src/agent_permissions.rs index 7aacea6a..8bb65c28 100644 --- a/swarm-controller/src/agent_permissions.rs +++ b/swarm-controller/src/agent_permissions.rs @@ -17,7 +17,7 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use backon::{ConstantBuilder, Retryable as _}; use futures_util::StreamExt as _; -use hive_types::Permission; +use hive_sh4re::permissions::Permission; use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply}; @@ -528,7 +528,7 @@ mod tests { use crate::test_nats::TestNats; use axum::extract::{Path, State}; use axum::http::StatusCode; - use hive_types::Permission; + use hive_sh4re::permissions::Permission; use swarm_queue_client::agent_permissions::{Import, ImportReply}; async fn published(client: &async_nats::Client, agent: &str) -> Option { diff --git a/swarm-controller/src/archetypes.rs b/swarm-controller/src/archetypes.rs index e2f31e81..1fb3a0dd 100644 --- a/swarm-controller/src/archetypes.rs +++ b/swarm-controller/src/archetypes.rs @@ -12,7 +12,7 @@ use std::time::Duration; use axum::Json; use axum::extract::State; -use hive_types::Permission; +use hive_sh4re::permissions::Permission; use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; use swarm_queue_client::archetypes::{ArchetypeMeta, BUILTIN_SOURCE, SourceRecord}; diff --git a/swarm-controller/src/message_stream.rs b/swarm-controller/src/message_stream.rs index 9f07e6fa..c130686b 100644 --- a/swarm-controller/src/message_stream.rs +++ b/swarm-controller/src/message_stream.rs @@ -32,7 +32,7 @@ use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::sse::{Event, KeepAlive, Sse}; use futures_util::{Stream, StreamExt as _, stream}; -use hive_types::term_msg::{SEND_ICON, TURN_ICON, send_recipient, turn_sender}; +use hive_sh4re::term_msg::{SEND_ICON, TURN_ICON, iso8601_utc, send_recipient, turn_sender}; use serde::Deserialize; use swarm_controller_api::messages::SwarmMessage; use swarm_queue_client::agent_term; @@ -125,17 +125,6 @@ fn message_event(message: &SwarmMessage) -> Event { Event::default().data(serde_json::to_string(message).unwrap_or_default()) } -/// A unix-seconds stamp as ISO 8601 / RFC 3339 UTC, the spelling a row's `ts` -/// carries, so the two compare as strings. It must equal -/// `hive_sh4re::term_msg::iso8601_utc`, which stamps those rows. -/// -/// A stamp outside the representable range renders as the epoch. -fn iso8601_utc(unix_seconds: i64) -> String { - chrono::DateTime::from_timestamp(unix_seconds, 0) - .unwrap_or_default() - .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) -} - /// The messages in the newest rows of each of `roster`'s streams from the last /// `minutes`, oldest first. An agent whose stream cannot be read adds none. async fn history( @@ -255,19 +244,6 @@ mod tests { use crate::test_nats::TestNats; - /// The spelling is the one the agents stamp rows with (RFC 3339, whole - /// seconds, `Z`): the history cutoff is compared with `ts` as a string. - #[test] - fn iso8601_utc_spells_a_stamp_the_way_a_row_ts_does() { - assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z"); - assert_eq!(iso8601_utc(1_789_302_903), "2026-09-13T12:35:03Z"); - } - - #[test] - fn iso8601_utc_renders_an_out_of_range_stamp_as_the_epoch() { - assert_eq!(iso8601_utc(i64::MAX), "1970-01-01T00:00:00Z"); - } - fn row(icon: &str, summary: &str, body: &str) -> bytes::Bytes { json!({ "ts": "2026-10-18T12:00:00Z", "icon": icon, "level": "info", diff --git a/swarm-controller/src/routes/agents.rs b/swarm-controller/src/routes/agents.rs index 7414b380..86b75330 100644 --- a/swarm-controller/src/routes/agents.rs +++ b/swarm-controller/src/routes/agents.rs @@ -510,7 +510,7 @@ pub(crate) struct CreateAgentRequest { /// agent holds the default set. #[serde(default)] #[schema(value_type = Option>)] - pub(crate) permissions: Option>, + pub(crate) permissions: Option>, /// The archetype to build the agent from, one `GET /api/archetypes` /// lists. The agent then gets no `agent-configs/` repo: its hive /// builds it from the archetype's module. Absent is a custom agent. @@ -1513,8 +1513,10 @@ mod tests { .await .expect("a bucket that refuses any value"); let state = crate::agent_limits::tests::state(&client); - let earlier = crate::agent_permissions::at_create(vec![hive_types::Permission::Messaging]) - .expect("valid"); + let earlier = crate::agent_permissions::at_create(vec![ + hive_sh4re::permissions::Permission::Messaging, + ]) + .expect("valid"); crate::agent_permissions::store(&client, "atlas", &earlier) .await .expect("an earlier grant"); diff --git a/swarm-queue-client/Cargo.toml b/swarm-queue-client/Cargo.toml index 7c8de72c..2f3f16f2 100644 --- a/swarm-queue-client/Cargo.toml +++ b/swarm-queue-client/Cargo.toml @@ -52,10 +52,10 @@ operator-profiles = ["async-nats/jetstream"] # runs the same way. Explicit for the same reason as `notices`. agent-limits = ["async-nats/jetstream"] # `agent_permissions::AgentPermissions` and `read`, for hive-c0re, the agent -# harness, the subagent daemon and swarm-controller. Pulls `hive-types` for the +# harness, the subagent daemon and swarm-controller. Pulls `hive-sh4re` for the # `Permission` names, so the crates that only need the bucket name do not link # it. -agent-permissions = ["dep:hive-types", "async-nats/jetstream"] +agent-permissions = ["dep:hive-sh4re", "async-nats/jetstream"] # `agent_archetype::read`, for hive-c0re, which reads the archetype of each # agent it provisions or rebuilds. Explicit for the same reason as `notices`. agent-archetype = ["async-nats/jetstream"] @@ -68,7 +68,7 @@ agents-md-initial = ["async-nats/jetstream"] # Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature # above - the connect itself needs none of them. async-nats.workspace = true -hive-types = { workspace = true, optional = true } +hive-sh4re = { workspace = true, optional = true } # `blocking` on top of the workspace default (`form`/`json`/`rustls`) — # `mint_token_for_blocking` needs `reqwest::blocking::Client` for a caller # with no tokio reactor to `.await` an async request on (an OTLP exporter's diff --git a/swarm-queue-client/src/agent_permissions.rs b/swarm-queue-client/src/agent_permissions.rs index 306fe6df..e2d379da 100644 --- a/swarm-queue-client/src/agent_permissions.rs +++ b/swarm-queue-client/src/agent_permissions.rs @@ -1,5 +1,5 @@ //! The per-agent permissions KV bucket: one key per agent, holding the -//! [`hive_types::Permission`] set the swarm grants it. +//! [`hive_sh4re::permissions::Permission`] set the swarm grants it. //! //! **swarm-controller is the only writer**, and creates the bucket when it //! starts. A missing key or an unset `permissions` field leaves the agent on @@ -71,14 +71,14 @@ pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct AgentPermissions { #[serde(default, skip_serializing_if = "Option::is_none")] - pub permissions: Option>, + pub permissions: Option>, } /// What a hive sends once: the permissions its meta repo held, by agent. #[cfg(feature = "agent-permissions")] #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Import { - pub permissions: std::collections::BTreeMap>, + pub permissions: std::collections::BTreeMap>, } /// The controller's answer to an [`Import`]. @@ -174,22 +174,22 @@ impl HiveCopy { /// What the agent holds before any read of its key has succeeded: the /// stored list, or the default when the hive stored none. #[must_use] - pub fn before_read(&self) -> Vec { + pub fn before_read(&self) -> Vec { self.stored .permissions .clone() - .unwrap_or_else(|| hive_types::Permission::DEFAULT.to_vec()) + .unwrap_or_else(|| hive_sh4re::permissions::Permission::DEFAULT.to_vec()) } /// What the agent holds after reading `read` from its key. An unset key /// while the import is pending may only mean the grant is not imported /// yet, so the stored list stands; otherwise unset is the default. #[must_use] - pub fn after_read(&self, read: &AgentPermissions) -> Vec { + pub fn after_read(&self, read: &AgentPermissions) -> Vec { match &read.permissions { Some(list) => list.clone(), None if self.import_pending => self.before_read(), - None => hive_types::Permission::DEFAULT.to_vec(), + None => hive_sh4re::permissions::Permission::DEFAULT.to_vec(), } } } @@ -280,7 +280,7 @@ mod tests { #[cfg(feature = "agent-permissions")] #[test] fn unset_and_empty_are_different_values() { - use hive_types::Permission; + use hive_sh4re::permissions::Permission; assert_eq!( serde_json::to_string(&AgentPermissions::default()).unwrap(), "{}" @@ -297,7 +297,7 @@ mod tests { #[cfg(feature = "agent-permissions")] #[test] fn the_hive_copy_stands_until_read_and_while_the_import_is_pending() { - use hive_types::Permission; + use hive_sh4re::permissions::Permission; let record = |json: &str| -> AgentPermissions { serde_json::from_str(json).unwrap() }; let copy = |json: &str| -> HiveCopy { serde_json::from_str(json).unwrap() }; let web = vec![Permission::WebTools]; diff --git a/swarmctl/src/permissions.rs b/swarmctl/src/permissions.rs index d1a67eb2..3bd15f28 100644 --- a/swarmctl/src/permissions.rs +++ b/swarmctl/src/permissions.rs @@ -6,7 +6,7 @@ //! Unset leaves the agent on the default set; an empty list grants nothing. //! //! The shape below mirrors the controller's `Permissions`, whose names are -//! `hive_types::Permission`, and `swarm-controller-api` does not +//! `hive_sh4re::permissions::Permission`, and `swarm-controller-api` does not //! depend on `hive-sh4re`. Permission names are checked by the controller, //! which answers 422 for one it does not know.