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
|
|
@ -44,6 +44,7 @@ serde_json.workspace = true
|
|||
swarm-queue-client = { workspace = true, features = [
|
||||
"agent-schedules",
|
||||
"agent-inference",
|
||||
"agent-permissions",
|
||||
] }
|
||||
# This agent's own queue secret, read from the store under its own certificate.
|
||||
swarm-secret-client.workspace = true
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ map of the module tree.
|
|||
- **`login.rs` / `login_session.rs`** — first-run and session-resume
|
||||
auth flow for the `claude` CLI.
|
||||
- **`mcp_config.rs`** — renders the per-turn `--mcp-config` /
|
||||
`--allowedTools` blob from tool groups + capabilities.
|
||||
`--allowedTools` blob from the agent's permissions.
|
||||
- **`todos.rs` / `reminders.rs` / `todo_server.rs`** — the harness-local
|
||||
loose-ends v2 stores (sqlite-backed) and the in-agent socket server
|
||||
extra MCP daemons + `hive-agent-mcp` dial into for todo/reminder ops.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ mod swarm_agent_icon;
|
|||
mod swarm_agent_state;
|
||||
mod swarm_inference;
|
||||
mod swarm_input;
|
||||
mod swarm_permissions;
|
||||
mod swarm_queue;
|
||||
mod swarm_term;
|
||||
mod term_msg;
|
||||
|
|
@ -496,6 +497,8 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
|||
swarm_agent_icon::spawn();
|
||||
// The model and effort the swarm sets for this agent, over its local ones.
|
||||
tokio::spawn(swarm_inference::run(bus.clone()));
|
||||
// The permissions the swarm grants this agent, read before each turn.
|
||||
tokio::spawn(swarm_permissions::run());
|
||||
// Set by the web UI's `/api/cancel` on a successful SIGINT, read-and-
|
||||
// cleared by `handle_turn` before building the next wake prompt — see
|
||||
// `hive_sh4re::inbox::INTERRUPTED_HINT`. Shared between the web server task and
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Claude launch-config layer: resolves the agent's tool-group / capability
|
||||
//! Claude launch-config layer: resolves the agent's permission
|
||||
//! set into the `--allowedTools` / `--tools` argument strings and renders the
|
||||
//! `--mcp-config` blob claude reads at spawn (built-in hyperhive server +
|
||||
//! any `services.hyperhive.agent.extraMcpServers`). Pure config-string generation consumed by
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
//! 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_sh4re::permissions::Permission;
|
||||
|
||||
/// Name of the hyperhive MCP server inside claude's view. Claude prefixes
|
||||
/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`).
|
||||
pub const SERVER_NAME: &str = "hyperhive";
|
||||
|
|
@ -18,87 +20,83 @@ pub const SERVER_NAME: &str = "hyperhive";
|
|||
/// so `127.0.0.1:<port>` is per-container-private (no cross-agent collision).
|
||||
pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790;
|
||||
|
||||
/// The built-in tool surface — the base list, the group-gated additions, the
|
||||
/// `HIVE_TOOL_GROUPS` parse, and the `--tools` value they resolve to — lives
|
||||
/// in `hive_sh4re::permissions`, next to `ToolGroup` itself. Re-exported here
|
||||
/// because this module is where the rest of the claude launch config is
|
||||
/// assembled, and because the subagent daemon (`hive-subagent-mcp`) spawns
|
||||
/// its own `claude` from the same base resolution (plus its own `Bash`
|
||||
/// exception — see `hive_sh4re::permissions::subagent_builtin_tools_arg`):
|
||||
/// `hive-agent` is a binary-only crate with no lib target, so a shared home
|
||||
/// was the only way for both spawners to read one list rather than two that
|
||||
/// drift.
|
||||
pub use hive_sh4re::permissions::{builtin_tools_arg, effective_tool_groups};
|
||||
/// The value for claude's `--tools` flag this turn: which built-in tools exist
|
||||
/// in the session at all, from the agent's current permissions. The
|
||||
/// resolution lives in `hive_sh4re::permissions` because the subagent daemon
|
||||
/// (`hive-subagent-mcp`) spawns its own `claude` from the same one.
|
||||
#[must_use]
|
||||
pub fn builtin_tools_arg() -> String {
|
||||
hive_sh4re::permissions::builtin_tools_for(&crate::swarm_permissions::current()).join(",")
|
||||
}
|
||||
|
||||
/// Tool group an extra (out-of-process) MCP server is gated behind, if any.
|
||||
/// Permission an extra (out-of-process) MCP server is gated behind, if any.
|
||||
///
|
||||
/// Most `services.hyperhive.agent.extraMcpServers` entries are ungated — available whenever
|
||||
/// the operator declares them. The `bash` server is the exception: raw shell
|
||||
/// execution is a privilege, so it is only exposed when the agent holds the
|
||||
/// `Execution` tool group. Unlike the in-process hyperhive tools (gated at
|
||||
/// dispatch) and the capability tools (re-checked server-side by hive-c0re),
|
||||
/// execution is a privilege, so it is only exposed when the agent holds
|
||||
/// `Execution`. Unlike the in-process hyperhive tools (gated at dispatch),
|
||||
/// an out-of-process server has **no** later enforcement point — once it is
|
||||
/// in the claude MCP config the agent can call it. So this gate, applied at
|
||||
/// config-render time, is the security boundary for those servers.
|
||||
fn extra_server_required_group(server: &str) -> Option<hive_sh4re::permissions::ToolGroup> {
|
||||
fn extra_server_required_permission(server: &str) -> Option<Permission> {
|
||||
match server {
|
||||
"bash" => Some(hive_sh4re::permissions::ToolGroup::Execution),
|
||||
"bash" => Some(Permission::Execution),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an extra MCP server should be exposed to claude given the active
|
||||
/// tool `groups`. A gated server (see [`extra_server_required_group`]) is
|
||||
/// suppressed when the agent lacks its required group.
|
||||
fn extra_server_enabled(server: &str, groups: &[hive_sh4re::permissions::ToolGroup]) -> bool {
|
||||
extra_server_required_group(server).is_none_or(|required| groups.contains(&required))
|
||||
/// Whether an extra MCP server should be exposed to claude given the agent's
|
||||
/// `perms`. A gated server (see [`extra_server_required_permission`]) is
|
||||
/// suppressed when the agent lacks its required permission.
|
||||
fn extra_server_enabled(server: &str, perms: &[Permission]) -> bool {
|
||||
extra_server_required_permission(server).is_none_or(|required| perms.contains(&required))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod extra_server_gate_tests {
|
||||
use super::{extra_server_enabled, extra_server_required_group};
|
||||
use hive_sh4re::permissions::ToolGroup;
|
||||
use super::{extra_server_enabled, extra_server_required_permission};
|
||||
use hive_sh4re::permissions::Permission;
|
||||
|
||||
#[test]
|
||||
fn bash_is_gated_behind_execution() {
|
||||
assert_eq!(
|
||||
extra_server_required_group("bash"),
|
||||
Some(ToolGroup::Execution)
|
||||
extra_server_required_permission("bash"),
|
||||
Some(Permission::Execution)
|
||||
);
|
||||
// Suppressed without Execution, even if other groups are present.
|
||||
assert!(!extra_server_enabled(
|
||||
"bash",
|
||||
&[ToolGroup::Messaging, ToolGroup::Inbox]
|
||||
&[Permission::Messaging, Permission::Inbox]
|
||||
));
|
||||
// Available once Execution is granted.
|
||||
assert!(extra_server_enabled("bash", &[ToolGroup::Execution]));
|
||||
assert!(extra_server_enabled("bash", &[Permission::Execution]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_servers_are_ungated() {
|
||||
assert_eq!(extra_server_required_group("matrix"), None);
|
||||
assert_eq!(extra_server_required_group("scraper"), None);
|
||||
assert_eq!(extra_server_required_permission("matrix"), None);
|
||||
assert_eq!(extra_server_required_permission("scraper"), None);
|
||||
// An ungated server is available regardless of (even empty) groups.
|
||||
assert!(extra_server_enabled("matrix", &[]));
|
||||
assert!(extra_server_enabled("scraper", &[ToolGroup::Messaging]));
|
||||
assert!(extra_server_enabled("scraper", &[Permission::Messaging]));
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP tools claude is allowed to call without prompting, derived from
|
||||
/// the supplied tool groups. Adding a new `#[tool]` fn to a server impl
|
||||
/// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re
|
||||
/// (single source of truth). See `docs/process/conventions.md::Tool groups`.
|
||||
/// the supplied permissions. Adding a new `#[tool]` fn to a server impl
|
||||
/// requires updating the matching `Permission::tools()` slice in hive-sh4re
|
||||
/// (single source of truth).
|
||||
#[must_use]
|
||||
pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<String> {
|
||||
pub fn allowed_mcp_tools(perms: &[Permission]) -> Vec<String> {
|
||||
// Collect all tool names, deduplicating while preserving order.
|
||||
// Always-on tools (e.g. `set_status`) come first so they're present
|
||||
// regardless of which groups the agent is granted — a misconfigured
|
||||
// regardless of which permissions the agent holds — a misconfigured
|
||||
// agent still has to be able to report its dashboard status.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out: Vec<String> = hive_sh4re::permissions::ToolGroup::ALWAYS_ON_TOOLS
|
||||
let mut out: Vec<String> = Permission::ALWAYS_ON_TOOLS
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(groups.iter().flat_map(|g| g.tools().iter().copied()))
|
||||
.chain(perms.iter().flat_map(|p| p.tools().iter().copied()))
|
||||
.filter(|t| seen.insert(*t))
|
||||
.map(|t| format!("mcp__{SERVER_NAME}__{t}"))
|
||||
.collect();
|
||||
|
|
@ -108,7 +106,7 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<S
|
|||
// them without per-tool operator approval. `["*"]` (the default)
|
||||
// expands to `mcp__<server>__*` — every tool from that server.
|
||||
for (server, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
|
||||
if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
|
||||
if server == SERVER_NAME || !extra_server_enabled(&server, perms) {
|
||||
continue;
|
||||
}
|
||||
for pat in spec.allowed_tools() {
|
||||
|
|
@ -122,14 +120,14 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<S
|
|||
/// both the built-ins and the MCP surface.
|
||||
#[must_use]
|
||||
pub fn allowed_tools_arg() -> String {
|
||||
let groups = effective_tool_groups();
|
||||
let perms = crate::swarm_permissions::current();
|
||||
// The same built-ins `--tools` makes exist this session, so a tool that
|
||||
// exists is never one claude has to prompt about.
|
||||
let mut all: Vec<String> = hive_sh4re::permissions::builtin_tools_for(&groups)
|
||||
let mut all: Vec<String> = hive_sh4re::permissions::builtin_tools_for(&perms)
|
||||
.into_iter()
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
all.extend(allowed_mcp_tools(&groups));
|
||||
all.extend(allowed_mcp_tools(&perms));
|
||||
all.join(",")
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +146,7 @@ pub fn render_claude_config() -> String {
|
|||
|
||||
/// The set of MCP server names claude is configured with this turn — the
|
||||
/// keys of the rendered `--mcp-config` (built-in hyperhive HTTP surface +
|
||||
/// any tool-group-permitted extra servers). The harness compares
|
||||
/// any permitted extra servers). The harness compares
|
||||
/// this against the per-turn `system`/`init` event's `mcp_servers` to
|
||||
/// detect a configured server that failed to connect or was dropped
|
||||
/// (the MCP-health instrumentation).
|
||||
|
|
@ -158,7 +156,7 @@ pub fn configured_server_names() -> Vec<String> {
|
|||
}
|
||||
|
||||
/// Build the `mcpServers` map claude gets in its `--mcp-config`: the
|
||||
/// built-in hyperhive HTTP surface plus any tool-group-permitted extra
|
||||
/// built-in hyperhive HTTP surface plus any permitted extra
|
||||
/// servers (stdio or http, per each entry's `type`). Shared by
|
||||
/// [`render_claude_config`] (serialises it) and [`configured_server_names`]
|
||||
/// (lists its keys) so the two never drift.
|
||||
|
|
@ -187,11 +185,11 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
|
|||
// (Http entries have no child-process env to inject into — the daemon
|
||||
// behind the URL resolves its own state dir independently.)
|
||||
let state_dir = crate::paths::state_dir();
|
||||
// Gate tool-group-restricted extra servers (e.g. `bash` → `Execution`).
|
||||
// Gate permission-restricted extra servers (e.g. `bash` → `Execution`).
|
||||
// This is the security boundary for them: an out-of-process server the
|
||||
// agent isn't entitled to must not even appear in the MCP config, or the
|
||||
// agent could call it directly (there is no later enforcement point).
|
||||
let groups = effective_tool_groups();
|
||||
let perms = crate::swarm_permissions::current();
|
||||
for (name, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
|
||||
if name == SERVER_NAME {
|
||||
tracing::warn!(
|
||||
|
|
@ -199,10 +197,10 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
|
|||
);
|
||||
continue;
|
||||
}
|
||||
if !extra_server_enabled(&name, &groups) {
|
||||
if !extra_server_enabled(&name, &perms) {
|
||||
tracing::info!(
|
||||
server = %name,
|
||||
"extra MCP server suppressed: agent lacks the required tool group"
|
||||
"extra MCP server suppressed: agent lacks the required permission"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -214,7 +212,7 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SERVER_NAME, allowed_mcp_tools};
|
||||
use hive_sh4re::permissions::ToolGroup;
|
||||
use hive_sh4re::permissions::Permission;
|
||||
|
||||
fn qualified(tool: &str) -> String {
|
||||
format!("mcp__{SERVER_NAME}__{tool}")
|
||||
|
|
@ -222,7 +220,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn set_status_present_with_no_groups() {
|
||||
// An agent with zero tool groups (or any group set that omits
|
||||
// An agent with no permissions (or any set that omits
|
||||
// `meta`) must still be able to report its dashboard status.
|
||||
let tools = allowed_mcp_tools(&[]);
|
||||
assert!(
|
||||
|
|
@ -234,7 +232,7 @@ mod tests {
|
|||
#[test]
|
||||
fn mark_todos_done_present_with_no_groups() {
|
||||
// Regression: mark_todos_done was declared as a
|
||||
// tool but never added to any ToolGroup, so no agent could ever
|
||||
// tool but never added to any group, so no agent could ever
|
||||
// get it into --allowedTools regardless of which groups it held —
|
||||
// including `inbox`, which only gates get_loose_ends/
|
||||
// cancel_loose_end/remind.
|
||||
|
|
@ -243,13 +241,13 @@ mod tests {
|
|||
tools.contains(&qualified("mark_todos_done")),
|
||||
"mark_todos_done missing from empty-group allow-list: {tools:?}"
|
||||
);
|
||||
let tools = allowed_mcp_tools(&[ToolGroup::Inbox]);
|
||||
let tools = allowed_mcp_tools(&[Permission::Inbox]);
|
||||
assert!(tools.contains(&qualified("mark_todos_done")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_status_present_without_meta_group() {
|
||||
let tools = allowed_mcp_tools(&[ToolGroup::Messaging, ToolGroup::Inbox]);
|
||||
let tools = allowed_mcp_tools(&[Permission::Messaging, Permission::Inbox]);
|
||||
assert!(tools.contains(&qualified("set_status")));
|
||||
// get_agent_meta stays gated behind `meta` — only set_status is always-on.
|
||||
assert!(!tools.contains(&qualified("get_agent_meta")));
|
||||
|
|
@ -257,7 +255,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn no_duplicate_set_status_when_meta_granted() {
|
||||
let tools = allowed_mcp_tools(&[ToolGroup::Meta]);
|
||||
let tools = allowed_mcp_tools(&[Permission::Meta]);
|
||||
let count = tools
|
||||
.iter()
|
||||
.filter(|t| **t == qualified("set_status"))
|
||||
|
|
|
|||
109
hive-agent/src/swarm_permissions.rs
Normal file
109
hive-agent/src/swarm_permissions.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! The permissions the swarm grants this agent.
|
||||
//!
|
||||
//! swarm-controller keeps the agent's set under its key in the
|
||||
//! `agent-permissions` KV bucket. This task reads that key every
|
||||
//! [`POLL_INTERVAL`] with the one subject the agent's own queue credential is
|
||||
//! granted there, and [`current`] answers with the last value read. The turn
|
||||
//! loop asks [`current`] for every turn's `--tools`, `--allowedTools` and MCP
|
||||
//! config, so a change reaches the next turn.
|
||||
//!
|
||||
//! Until the first successful read after a start, and for an unset key, the
|
||||
//! agent holds [`Permission::default_for`]. A failed read keeps the last
|
||||
//! value.
|
||||
|
||||
use std::sync::{PoisonError, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use hive_sh4re::permissions::Permission;
|
||||
use swarm_queue_client::agent_permissions::{AgentPermissions, Read, read};
|
||||
|
||||
/// How often the key is read. Also the longest a change can take to reach
|
||||
/// the agent's next turn.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// The last value read; `None` until the first read, which is the default.
|
||||
static CURRENT: RwLock<Option<Vec<Permission>>> = RwLock::new(None);
|
||||
|
||||
/// The permissions this agent holds now.
|
||||
#[must_use]
|
||||
pub fn current() -> Vec<Permission> {
|
||||
CURRENT
|
||||
.read()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clone()
|
||||
.unwrap_or_else(|| Permission::default_for(&crate::identity::label()))
|
||||
}
|
||||
|
||||
/// What one read of the key leaves the agent holding: the swarm's list when
|
||||
/// it sets one, the default when the key is unset, and `None` (keep the last
|
||||
/// value) when the read failed or the bucket did not answer.
|
||||
fn after(agent: &str, read: Option<Read>) -> Option<Vec<Permission>> {
|
||||
match read? {
|
||||
Read::Current(AgentPermissions { permissions }) => {
|
||||
Some(permissions.unwrap_or_else(|| Permission::default_for(agent)))
|
||||
}
|
||||
Read::NoBucket => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the poller for the harness's lifetime, if this agent has a label to
|
||||
/// name its key with.
|
||||
pub async fn run() {
|
||||
let agent = crate::identity::label();
|
||||
if agent.is_empty() {
|
||||
tracing::warn!("this agent has no label; not reading its swarm permissions");
|
||||
return;
|
||||
}
|
||||
let client = crate::swarm_queue::client().await;
|
||||
tracing::info!(%agent, "reading swarm permissions from the swarm queue");
|
||||
loop {
|
||||
if let Some(perms) = after(&agent, read_once(&client, &agent).await) {
|
||||
let mut current = CURRENT.write().unwrap_or_else(PoisonError::into_inner);
|
||||
if current.as_ref() != Some(&perms) {
|
||||
tracing::info!(?perms, "permissions: the swarm's set changed");
|
||||
*current = Some(perms);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The key's current value, or `None` when it could not be read.
|
||||
async fn read_once(client: &async_nats::Client, agent: &str) -> Option<Read> {
|
||||
// An unconnected client does not fail a JetStream request, it hangs on it.
|
||||
swarm_queue_client::ensure_connected(client).ok()?;
|
||||
match read(client, agent).await {
|
||||
Ok(read) => Some(read),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "permissions: reading this agent's key failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn record(json: &str) -> Read {
|
||||
Read::Current(serde_json::from_str(json).expect("decodes"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_sets_the_list_or_the_default_and_a_failure_keeps_the_last() {
|
||||
assert_eq!(
|
||||
after("atlas", Some(record(r#"{"permissions":["web_tools"]}"#))),
|
||||
Some(vec![Permission::WebTools])
|
||||
);
|
||||
assert_eq!(
|
||||
after("atlas", Some(record(r#"{"permissions":[]}"#))),
|
||||
Some(Vec::new())
|
||||
);
|
||||
assert_eq!(
|
||||
after("atlas", Some(record("{}"))),
|
||||
Some(Permission::DEFAULT.to_vec())
|
||||
);
|
||||
assert_eq!(after("atlas", Some(Read::NoBucket)), None);
|
||||
assert_eq!(after("atlas", None), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -91,10 +91,12 @@ and end the turn.";
|
|||
/// prompt (`--system-prompt-file`). Static settings are no longer
|
||||
/// passed here — they live at `/etc/claude-code/managed-settings.json`
|
||||
/// and claude auto-discovers them.
|
||||
/// Materialised once at harness startup; shared between the turn loop
|
||||
/// and the operator-driven `/compact` path so both invocations look
|
||||
/// identical to claude (same MCP surface, same allowed tools, same
|
||||
/// role prompt — only the stdin payload differs).
|
||||
/// Materialised at harness startup; shared between the turn loop and the
|
||||
/// operator-driven `/compact` path so both invocations look identical to
|
||||
/// claude (same MCP surface, same allowed tools, same role prompt — only the
|
||||
/// stdin payload differs). The MCP config is rewritten before each
|
||||
/// invocation ([`TurnFiles::refresh_mcp_config`]), since which servers it
|
||||
/// holds follows the agent's current permissions.
|
||||
#[derive(Clone)]
|
||||
pub struct TurnFiles {
|
||||
pub mcp_config: PathBuf,
|
||||
|
|
@ -114,6 +116,16 @@ impl TurnFiles {
|
|||
system_prompt: write_system_prompt(socket, label).await?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rewrite the MCP config from the agent's current permissions, so a
|
||||
/// change to `execution` reaches this invocation's `bash` server. A
|
||||
/// failed write is logged and the previous file stays.
|
||||
pub async fn refresh_mcp_config(&self) {
|
||||
if let Err(e) = tokio::fs::write(&self.mcp_config, mcp_config::render_claude_config()).await
|
||||
{
|
||||
tracing::warn!(path = %self.mcp_config.display(), error = %e, "rewriting the claude MCP config failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
||||
|
|
@ -335,22 +347,25 @@ pub fn make_session(bus: &Bus) -> Result<AgentSession> {
|
|||
})
|
||||
}
|
||||
|
||||
/// The answer to an ACP agent's permission requests, see [`acp_permits`].
|
||||
/// The answer to an ACP agent's permission requests, see [`acp_permits`],
|
||||
/// against the agent's permissions at the moment it asks.
|
||||
fn acp_permission_policy() -> PermissionPolicy {
|
||||
let web =
|
||||
mcp_config::effective_tool_groups().contains(&hive_sh4re::permissions::ToolGroup::WebTools);
|
||||
std::sync::Arc::new(move |ask: &PermissionAsk<'_>| acp_permits(ask, web))
|
||||
std::sync::Arc::new(|ask: &PermissionAsk<'_>| {
|
||||
let web = crate::swarm_permissions::current()
|
||||
.contains(&hive_sh4re::permissions::Permission::WebTools);
|
||||
acp_permits(ask, web)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an ACP agent may run the tool call it asks about. Default-deny,
|
||||
/// allowing what a claude agent has:
|
||||
///
|
||||
/// - `other`-kind calls to tools of the MCP servers the session was handed
|
||||
/// — which of those an agent gets is already decided by its tool groups
|
||||
/// — which of those an agent gets is already decided by its permissions
|
||||
/// (`mcp_config`);
|
||||
/// - the file tools, mirroring the claude built-ins
|
||||
/// (`hive_sh4re::permissions`): `read`, `edit`, `search`;
|
||||
/// - `fetch` with the `web_tools` group (`web`).
|
||||
/// - `fetch` with the `web_tools` permission (`web`).
|
||||
///
|
||||
/// Everything else is refused, including a built-in shell (`execute`) —
|
||||
/// shell goes through `mcp__bash__run` — and any kind this list doesn't name.
|
||||
|
|
@ -395,6 +410,7 @@ pub async fn drive_turn(
|
|||
// Heuristic: context large AND prompt cache gone cold.
|
||||
maybe_auto_reset(bus, session);
|
||||
}
|
||||
files.refresh_mcp_config().await;
|
||||
let config = claude_config(bus, files);
|
||||
let sink = BusSink::new(bus);
|
||||
let mut result = session.run(&config, prompt, &sink).await;
|
||||
|
|
@ -621,6 +637,7 @@ pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus, session: &AgentSe
|
|||
text: "operator: /compact — running on idle session".into(),
|
||||
});
|
||||
bus.set_state(crate::events::TurnState::Compacting);
|
||||
files.refresh_mcp_config().await;
|
||||
let config = claude_config(bus, files);
|
||||
let sink = BusSink::new(bus);
|
||||
report_compact(bus, session.compact(&config, &sink).await);
|
||||
|
|
|
|||
Loading…
Reference in a new issue