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:
parent
f862354e43
commit
f0844f6da3
73 changed files with 1578 additions and 2742 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue