diff --git a/hive-ag3nt/prompts/claude-settings.json b/hive-ag3nt/prompts/claude-settings.json index 612237de..56c2ef93 100644 --- a/hive-ag3nt/prompts/claude-settings.json +++ b/hive-ag3nt/prompts/claude-settings.json @@ -1,5 +1,9 @@ { "autoCompactEnabled": false, "autoMemoryEnabled": false, - "effortLevel": "medium" + "effortLevel": "medium", + "permissions": { + "defaultMode": "bypassPermissions", + "deny": ["WebFetch", "WebSearch", "Task", "TodoWrite"] + } } diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 65dd0a18..8d6741b1 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -6,7 +6,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use hive_ag3nt::events::{Bus, LiveEvent, TurnState}; use hive_ag3nt::login::{self, LoginState}; -use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui}; +use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, turn, web_ui}; use hive_sh4re::{AgentRequest, AgentResponse}; #[derive(Parser)] @@ -71,6 +71,7 @@ async fn main() -> Result<()> { let login_state = Arc::new(Mutex::new(initial)); let bus = Bus::new(); let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Agent).await?; + plugins::install_configured().await; tokio::spawn(web_ui::serve( label, port, diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index 42062e5a..b56e9b4c 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -10,7 +10,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use hive_ag3nt::events::{Bus, LiveEvent, TurnState}; use hive_ag3nt::login::{self, LoginState}; -use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui}; +use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, turn, web_ui}; use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER}; #[derive(Parser)] @@ -61,6 +61,7 @@ async fn main() -> Result<()> { let login_state = Arc::new(Mutex::new(initial)); let bus = Bus::new(); let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Manager).await?; + plugins::install_configured().await; tokio::spawn(web_ui::serve( label, port, diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index 234939bb..d29f3938 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -6,6 +6,7 @@ pub mod events; pub mod login; pub mod login_session; pub mod mcp; +pub mod plugins; pub mod turn; pub mod web_ui; diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 14c23118..a31a791d 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -548,86 +548,23 @@ impl ServerHandler for ManagerServer {} /// tools as `mcp____` (e.g. `mcp__hyperhive__send`). pub const SERVER_NAME: &str = "hyperhive"; -/// Built-in claude tools the turn loop enables via `--tools`. Anything not -/// in this list literally doesn't exist in the session (claude won't even -/// try to call it). Web egress (`WebFetch`/`WebSearch`) and nested agents -/// (`Task`) are intentionally omitted for now; `Bash` is allowed pending a -/// finer-grained allow-list system for shell command patterns. Edit later -/// as our trust model evolves. -pub const ALLOWED_BUILTIN_TOOLS: &[&str] = - &["Bash", "Edit", "Glob", "Grep", "Read", "TodoWrite", "Write"]; - -/// Which MCP tool surface to advertise via `--allowedTools`. The agent -/// list is the strict subset of the manager list, so we just thread the -/// flavor through. +/// Which hyperhive MCP surface to advertise — sub-agent (short tool +/// list) or manager (full lifecycle surface). Threaded through the +/// system-prompt renderer and the per-flavor web UI dispatch; tool +/// gating itself now lives in `claude-settings.json`'s +/// `permissions.{defaultMode, deny}`, not here. #[derive(Debug, Clone, Copy)] pub enum Flavor { Agent, Manager, } -/// MCP tools claude is allowed to call without prompting. Mirrors the -/// hyperhive surface so a new tool added in the corresponding `#[tool_router]` -/// impl needs to be listed here too. -#[must_use] -pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { - let names: &[&str] = match flavor { - Flavor::Agent => &["send", "recv", "ask_operator"], - Flavor::Manager => &[ - "send", - "recv", - "request_spawn", - "kill", - "start", - "restart", - "update", - "request_apply_commit", - "ask_operator", - ], - }; - let mut out: Vec = names - .iter() - .map(|t| format!("mcp__{SERVER_NAME}__{t}")) - .collect(); - // Extra MCP servers declared via `hyperhive.extraMcpServers` in - // the agent's NixOS config. Each entry maps its `allowedTools` - // pattern list to `mcp____` so claude can call - // them without per-tool operator approval. `["*"]` (the default) - // expands to `mcp____*` — every tool from that server. - for (server, spec) in load_extra_mcp() { - if server == SERVER_NAME { - continue; - } - for pat in spec.allowed_tools { - out.push(format!("mcp__{server}__{pat}")); - } - } - out -} - -/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers -/// both the built-ins and the MCP surface. -#[must_use] -pub fn allowed_tools_arg(flavor: Flavor) -> String { - let mut all: Vec = ALLOWED_BUILTIN_TOOLS - .iter() - .map(|s| (*s).to_owned()) - .collect(); - all.extend(allowed_mcp_tools(flavor)); - all.join(",") -} - -/// Built-in tools list for `--tools` (which built-ins exist in this -/// session). Same as `ALLOWED_BUILTIN_TOOLS` but joined comma-separated. -#[must_use] -pub fn builtin_tools_arg() -> String { - ALLOWED_BUILTIN_TOOLS.join(",") -} - /// Where the NixOS module writes the per-agent extra-MCP spec (see /// `nix/templates/harness-base.nix`). Each entry becomes an additional -/// `mcpServers.` block in the rendered claude config + a -/// `mcp____` pattern in `--allowedTools`. +/// `mcpServers.` block in the rendered claude config; the +/// `allowedTools` field is parsed for back-compat but no longer wired +/// anywhere — under `bypassPermissions` every MCP tool auto-approves +/// unless listed in `permissions.deny`. const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; /// Where the NixOS module writes the per-agent send allow-list (see @@ -687,6 +624,7 @@ struct ExtraMcpServer { env: std::collections::BTreeMap, #[serde(default = "default_allowed_tools")] #[serde(rename = "allowedTools")] + #[allow(dead_code)] // back-compat: superseded by `permissions.deny` allowed_tools: Vec, } diff --git a/hive-ag3nt/src/plugins.rs b/hive-ag3nt/src/plugins.rs new file mode 100644 index 00000000..70d20457 --- /dev/null +++ b/hive-ag3nt/src/plugins.rs @@ -0,0 +1,48 @@ +//! Boot-time `claude plugin install` driver. Reads the list declared +//! via the `hyperhive.claudePlugins` NixOS option (rendered to +//! `/etc/hyperhive/claude-plugins.json` by the harness module) and +//! shells out `claude plugin install ` for each entry. Runs once +//! per harness boot before the turn loop; `claude plugin install` +//! is expected to be idempotent so reinstalling on each container +//! recreate is fine. Failures log a warning but do not abort boot — +//! we'd rather start without a plugin than refuse to serve. + +use tokio::process::Command; + +const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json"; + +pub async fn install_configured() { + let raw = match tokio::fs::read_to_string(PLUGINS_PATH).await { + Ok(s) => s, + Err(_) => return, + }; + let specs: Vec = match serde_json::from_str(&raw) { + Ok(v) => v, + Err(e) => { + tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping"); + return; + } + }; + for spec in specs { + match Command::new("claude") + .args(["plugin", "install", &spec]) + .output() + .await + { + Ok(out) if out.status.success() => { + tracing::info!(spec = %spec, "claude plugin install ok"); + } + Ok(out) => { + tracing::warn!( + spec = %spec, + status = ?out.status, + stderr = %String::from_utf8_lossy(&out.stderr), + "claude plugin install failed", + ); + } + Err(e) => { + tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed"); + } + } + } +} diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 6b1ee0ce..89a614ce 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -22,9 +22,13 @@ use crate::mcp; /// to read and edit; we ship it via `include_str!`. We turn off claude's /// in-session auto-compaction and its cross-session auto-memory because /// hyperhive owns those concerns (`/compact` on overflow, notes -/// persistence under `/state`). Unknown keys are silently ignored by -/// claude-code; if a key gets renamed we'll spot it because the -/// corresponding behavior will start firing mid-turn again. +/// persistence under `/state`). `permissions.defaultMode = +/// bypassPermissions` skips the per-tool approval prompt entirely; +/// `permissions.deny` keeps a short list of tools we don't want claude +/// reaching for (web egress, nested agents, the ephemeral todo list). +/// Unknown keys are silently ignored by claude-code; if a key gets +/// renamed we'll spot it because the corresponding behavior will start +/// firing mid-turn again. const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json"); /// Regex-ish marker claude-code emits when context overflows. Same string @@ -81,7 +85,10 @@ pub async fn write_mcp_config(socket: &Path) -> Result { /// Drop the static `--settings` JSON next to the MCP config so we can /// pass a path (`--settings `) instead of an ever-growing inline -/// blob — the CLI argv has a finite length budget. +/// blob — the CLI argv has a finite length budget. The file carries +/// `permissions.defaultMode = bypassPermissions` + a small `deny` list, +/// so everything not in `deny` auto-approves without a per-flavor allow +/// list. pub async fn write_settings(socket: &Path) -> Result { let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); tokio::fs::create_dir_all(parent).await.ok(); @@ -247,11 +254,7 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result cmd.arg("--system-prompt-file").arg(&files.system_prompt); cmd.arg("--mcp-config") .arg(&files.mcp_config) - .arg("--strict-mcp-config") - .arg("--tools") - .arg(mcp::builtin_tools_arg()) - .arg("--allowedTools") - .arg(mcp::allowed_tools_arg(files.flavor)); + .arg("--strict-mcp-config"); let mut child = cmd .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 2e682fa4..c9b4b267 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -82,6 +82,22 @@ ''; }; + options.hyperhive.claudePlugins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "formatter@my-marketplace" "thinking-tools@anthropics" ]; + description = '' + Claude Code plugins to install at harness boot. Each entry is + passed verbatim to `claude plugin install ` once per + container start, before the turn loop opens. `claude plugin + install` is expected to be idempotent, so reinstalling on every + boot is cheap. Failures log a warning but do not abort boot — a + missing plugin is preferable to a non-serving agent. Rendered to + `/etc/hyperhive/claude-plugins.json`; the harness reads it via + `plugins::install_configured`. + ''; + }; + config = { environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; @@ -89,6 +105,9 @@ environment.etc."hyperhive/send-allow.json".text = builtins.toJSON config.hyperhive.allowedRecipients; + environment.etc."hyperhive/claude-plugins.json".text = + builtins.toJSON config.hyperhive.claudePlugins; + boot.isNspawnContainer = true; # `claude-code` is unfree. Each per-agent container's nixosConfiguration