Watch
0
0
Fork
You've already forked hyperhive
0

agent permissions: one set, tool groups included, set at agent creation

mara on #5170: "dont split caps and perms. its all perms. / dont include
the now empty tool groups / nothing set -> default / also add perms to
agent creation screen".

- hive-sh4re: one `Permission` enum (messaging, meta, inbox, execution,
  web_tools, manage_root_agent) replaces `ToolGroup` and `Capability`;
  `lifecycle`/`forge` are gone. `Permission::default_for` is the default
  for an agent the swarm sets nothing for.
- The record is `{ "permissions": [...] }`. Missing key, unset field or
  no successful read yet = default; `[]` = none; a failed read after a
  good one keeps the last value.
- hive-agent reads its own key every 10s and builds each turn's --tools,
  --allowedTools and MCP config (rewritten before every turn) from it;
  the subagent daemon reads it on each start/continue. Agents get a
  read-only grant on their own key.
- hive-c0re restarts an agent only when its manage_root_agent changes.
  The import now merges meta/capabilities.json and meta/tool-groups.json
  into one list per agent and deletes both; the "import pending, don't
  read" decision is `ImportOutcome::read_allowed`, table-tested.
- swarm-controller: `GET /api/permissions` (names + default) and an
  optional `permissions` on `POST /api/agents`, stored before the job is
  queued. swarm-ui's create-agent form gets a permissions field.
- Deleted: HIVE_TOOL_GROUPS and its forward to the subagent daemon,
  tool_groups.rs, ruth's tool-group seed, the permissions rebuild job
  (PermPayload/WritePermFile/perm_change), the dashboard permissions tab
  and its stale-entries panel.

Part of #5170.
This commit is contained in:
atlas 2026-10-15 04:09:53 +02:00 • committed by mara
commit f0844f6da3
73 changed files with 1578 additions and 2742 deletions

View file

@ -16,7 +16,7 @@ clap.workspace = true
hive-agent-sock.workspace = true
hive-claude.workspace = true
hive-runtime.workspace = true
# `permissions::builtin_tools_arg` — the same `--tools` resolution the parent
# `permissions::subagent_builtin_tools_for` — the same `--tools` resolution the parent
# harness spawns its own claude with, so a subagent's built-in surface is its
# parent's rather than a second list that drifts. See `session::build_config`.
# Also `term_msg`/`stream_enrich`, the rows `swarm_term` publishes.
@ -30,7 +30,10 @@ serde.workspace = true
serde_json.workspace = true
# `subagent_term`: the subject each subagent's rows are published on.
# `agent-inference`: this agent's `subagent_models`, read with one direct get.
swarm-queue-client = { workspace = true, features = ["agent-inference"] }
swarm-queue-client = { workspace = true, features = [
"agent-inference",
"agent-permissions",
] }
# The agent's own queue credential, read under its store identity.
swarm-secret-client.workspace = true
tokio.workspace = true

View file

@ -98,7 +98,7 @@ use hive_claude::{Attach, Claude, Config, SessionStore};
use hive_runtime::{
AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop,
};
use hive_sh4re::permissions::ToolGroup;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_inference::AgentInference;
use tokio::sync::oneshot;
@ -552,12 +552,16 @@ impl State {
command
}
/// Read the swarm's key once, refuse a `model` outside the list in effect
/// for this agent (see `check_model`), and return the subagent cap in
/// effect (see `subagent_cap`).
/// Read the swarm's keys once: refresh this agent's permissions (the
/// tools the subagent about to run gets), refuse a `model` outside the list
/// in effect for this agent (see `check_model`), and return the subagent
/// cap in effect (see `subagent_cap`).
async fn swarm_limits(&self, model: Option<&str>) -> anyhow::Result<Option<u32>> {
let read = match &self.models {
Some(reader) => Some(reader.read().await),
Some(reader) => {
reader.refresh_permissions().await;
Some(reader.read().await)
}
None => None,
};
let agent = self
@ -1051,7 +1055,7 @@ fn validate_name(name: &str) -> anyhow::Result<()> {
/// Env var carrying the nix-configured `services.hyperhive.agent.availableModels` as a
/// comma-separated list, forwarded onto this daemon's unit by
/// `nix/agent-modules/mcp.nix` the same way `HIVE_TOOL_GROUPS` is.
/// `nix/agent-modules/mcp.nix` from the harness unit.
const AVAILABLE_MODELS_ENV: &str = "HIVE_AVAILABLE_MODELS";
/// The models this daemon may spawn a subagent on, from
@ -1136,7 +1140,7 @@ fn check_model(model: Option<&str>, list: &ModelList) -> anyhow::Result<()> {
"the swarm's subagent_models for this agent",
),
ModelList::Local(None) => return Ok(()),
// Same split/trim/skip-empty rules as `ToolGroup::parse_list`.
// Split, trim, skip empty tokens.
ModelList::Local(Some(env)) => (
env.split(',')
.map(str::trim)
@ -1186,7 +1190,8 @@ fn subagent_otel_attrs(name: &str) -> String {
/// gates covering one half each, neither substituting for the other:
/// `strict_mcp_config` over **MCP** tools (no ambient discovery, so exactly
/// what [`crate::mcp_config::build`] renders), `--tools` over **built-in**
/// ones, via [`hive_sh4re::permissions::subagent_builtin_tools_arg`], which
/// ones, via [`hive_sh4re::permissions::subagent_builtin_tools_for`] over the
/// agent's permissions ([`crate::swarm_models::permissions_now`]), which
/// documents its one `Bash` exception. `--tools` does not filter `mcp__*`,
/// so the signal tools are unnamed in it (`docs/tools/subagent.md`).
///
@ -1208,10 +1213,13 @@ fn build_config(
dir: Option<&str>,
signal_url: Option<&str>,
) -> Config {
let tools = hive_sh4re::permissions::subagent_builtin_tools_arg();
let tools = hive_sh4re::permissions::subagent_builtin_tools_for(
&crate::swarm_models::permissions_now(),
)
.join(",");
// Refuse to spawn on an empty `--tools` rather than reason about what it
// would do: what an empty value means is disputed and depends on the
// installed claude release (see `builtin_tools_arg`). Nothing here wants
// installed claude release (see `builtin_tools_for`). Nothing here wants
// "a subagent with no built-ins" anyway, so an empty resolution is a bug
// either way, and failing on it is safe under every reading.
assert!(
@ -2063,9 +2071,9 @@ fn acp_runtime(
sessions: &Path,
name: &str,
) -> AcpRuntime<hive_claude::NeverCompact> {
let groups = hive_sh4re::permissions::effective_tool_groups();
let perms = crate::swarm_models::permissions_now();
let permit: PermissionPolicy =
Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, &groups));
Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, &perms));
AcpRuntime::new(
command,
acp_session_file(sessions, name),
@ -2079,15 +2087,15 @@ fn acp_runtime(
///
/// - `other`-kind calls to tools of the MCP servers the session was handed;
/// - the file tools: `read`, `edit`, `search`;
/// - `fetch` with the `web_tools` group;
/// - `execute` with the `execution` group, as a claude subagent gets `Bash`
/// - `fetch` with the `web_tools` permission;
/// - `execute` with the `execution` permission, as a claude subagent gets `Bash`
/// ([`hive_sh4re::permissions::subagent_builtin_tools_for`]).
fn acp_permits(ask: &PermissionAsk<'_>, groups: &[ToolGroup]) -> bool {
fn acp_permits(ask: &PermissionAsk<'_>, perms: &[Permission]) -> bool {
match ask.kind {
"other" => ask.mcp_server.is_some(),
"read" | "edit" | "search" => true,
"fetch" => groups.contains(&ToolGroup::WebTools),
"execute" => groups.contains(&ToolGroup::Execution),
"fetch" => perms.contains(&Permission::WebTools),
"execute" => perms.contains(&Permission::Execution),
_ => false,
}
}
@ -2984,15 +2992,13 @@ mod tests {
/// parent actually licenses rather than against a restatement of it that
/// could drift.
///
/// ⚠️ **Scope: this catches a code divergence, not an environment one.**
/// Both sides resolve in one process off one `HIVE_TOOL_GROUPS`, so it
/// cannot see the two real units disagreeing about that var — which they
/// did until `nix/agent-modules/mcp.nix` forwarded it onto this daemon.
/// Nothing in a unit test reaches that; it is a deployment property.
/// ⚠️ **Scope: this catches a code divergence, not a read one.** Both
/// sides resolve off one permission set here; that the parent and this
/// daemon read the same key is a deployment property.
#[test]
fn a_subagent_gets_no_builtin_its_parent_lacks() {
let allowed = hive_sh4re::permissions::subagent_builtin_tools_for(
&hive_sh4re::permissions::effective_tool_groups(),
&crate::swarm_models::permissions_now(),
);
let config = build_config("n", None, None, None, None, None);
for tool in spawned_tools(&config).split(',') {
@ -5412,8 +5418,8 @@ echo '{"type":"result","subtype":"success","is_error":false}'
kind,
mcp_server: None,
};
let none: &[ToolGroup] = &[];
let both = &[ToolGroup::WebTools, ToolGroup::Execution];
let none: &[Permission] = &[];
let both = &[Permission::WebTools, Permission::Execution];
for kind in ["read", "edit", "search"] {
assert!(acp_permits(&ask(kind), none), "{kind}");
}

View file

@ -1,11 +1,13 @@
//! Reads the subagent settings the swarm sets for this agent.
//! Reads the subagent settings and permissions the swarm sets for this agent.
//!
//! swarm-controller keeps the agent's settings under its key in the
//! `agent-inference` KV bucket. Its `subagent_models`, when set, is the list
//! a `start` or `continue` naming a `model` is checked against, in place of
//! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`). Its
//! `max_subagents`, when set, caps how many subagents run at once
//! (`crate::session::State::reserve_under`).
//! (`crate::session::State::reserve_under`). The agent's permissions, under
//! its key in `agent-permissions`, decide a new subagent's tools
//! ([`permissions_now`]).
//!
//! **Read on every `start` and `continue`**, never cached, with the one subject the
//! agent's own queue credential is granted in that bucket
@ -23,10 +25,27 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_inference::{self, AgentInference, read_own};
use swarm_queue_client::agent_permissions;
use crate::swarm_term::{Target, connect};
/// The last permission set [`Reader::refresh_permissions`] read; `None` until
/// one succeeds.
static LAST_PERMISSIONS: std::sync::RwLock<Option<Vec<Permission>>> = std::sync::RwLock::new(None);
/// The permissions this agent holds now, for the subagent being spawned: the
/// last set read, or [`Permission::DEFAULT`] before any read succeeds.
#[must_use]
pub fn permissions_now() -> Vec<Permission> {
LAST_PERMISSIONS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
.unwrap_or_else(|| Permission::DEFAULT.to_vec())
}
/// How long after a failed connect the task tries again.
const RETRY_AFTER: Duration = Duration::from_mins(1);
@ -126,6 +145,46 @@ impl Reader {
}
}
}
/// Read the permissions the swarm grants this agent and keep them for
/// [`permissions_now`]: its list, or the default for an unset key. A read
/// that fails keeps the last value.
pub async fn refresh_permissions(&self) {
let read = match &self.source {
Source::Queue { agent, client } => read_permissions(agent, client).await,
#[cfg(test)]
Source::Fixed { .. } => None,
};
if let Some(perms) = read {
*LAST_PERMISSIONS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(perms);
}
}
}
async fn read_permissions(
agent: &str,
client: &OnceLock<async_nats::Client>,
) -> Option<Vec<Permission>> {
let client = client.get()?;
swarm_queue_client::ensure_connected(client).ok()?;
match tokio::time::timeout(READ_TIMEOUT, agent_permissions::read(client, agent)).await {
Ok(Ok(agent_permissions::Read::Current(record))) => Some(
record
.permissions
.unwrap_or_else(|| Permission::default_for(agent)),
),
Ok(Ok(agent_permissions::Read::NoBucket)) => None,
Ok(Err(e)) => {
tracing::warn!(error = %e, "subagent permissions: reading this agent's key failed");
None
}
Err(_) => {
tracing::warn!("subagent permissions: no answer within {READ_TIMEOUT:?}");
None
}
}
}
async fn read_queue(agent: &str, client: &OnceLock<async_nats::Client>) -> Read {