From 3c672ed6b20a3e9c15549afce6507e2f05f761ff Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 20 May 2026 13:08:15 +0200 Subject: [PATCH] add allowedBashPatterns NixOS option for finer-grained Bash tool approval --- docs/turn-loop.md | 20 +++++++++---- hive-ag3nt/src/mcp.rs | 52 ++++++++++++++++++++++++++++++---- nix/templates/harness-base.nix | 26 +++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/docs/turn-loop.md b/docs/turn-loop.md index bfcfdbc..5c9c411 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -276,11 +276,21 @@ status hint moved to the wake prompt + UI header. ### Tool whitelist (`mcp::ALLOWED_BUILTIN_TOOLS`) -- Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `Read`, - `TodoWrite`, `Write`. +- Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `Read`, `Write`. - Denied by omission: `WebFetch`, `WebSearch`, `Task`, - `NotebookEdit`. + `NotebookEdit`, `TodoWrite`. - Allowed MCP tools: as listed above per flavor. -`Bash` is on the allow-list pending a finer-grained pattern allow-list -(`Bash(git *)`-style) — see [issue #21](http://localhost:3000/hyperhive/hyperhive/issues/21). +By default `Bash` is approved wholesale — any shell command runs +without confirmation. To restrict an agent to specific command +families, set `hyperhive.allowedBashPatterns` in its `agent.nix`: + +```nix +hyperhive.allowedBashPatterns = [ "git *" "ls *" ]; +``` + +The harness reads `/etc/hyperhive/bash-allow.json` and replaces +`Bash` in `--allowedTools` with `Bash(git *)` + `Bash(ls *)` etc. +Commands outside the pattern list require confirmation — which in +`--print` mode means they will not run. An empty list (default) keeps +the current wholesale `Bash` entry. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index e06fa84..2588a28 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -1269,12 +1269,30 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { } /// Combined allow-list passed to `--allowedTools` (auto-approve) — covers -/// both the built-ins and the MCP surface. +/// both the built-ins and the MCP surface. If `hyperhive.allowedBashPatterns` +/// is configured (non-empty list in `/etc/hyperhive/bash-allow.json`), +/// `Bash` is replaced with one `Bash(pattern)` entry per pattern so +/// only vetted command families auto-approve without a blanket shell grant. +/// An empty or missing allow file keeps the current wholesale `Bash` entry. #[must_use] pub fn allowed_tools_arg(flavor: Flavor) -> String { let mut all: Vec = ALLOWED_BUILTIN_TOOLS .iter() - .map(|s| (*s).to_owned()) + .flat_map(|s| { + if *s == "Bash" { + let patterns = load_bash_allow(); + if patterns.is_empty() { + vec!["Bash".to_owned()] + } else { + patterns + .into_iter() + .map(|p| format!("Bash({p})")) + .collect() + } + } else { + vec![(*s).to_owned()] + } + }) .collect(); all.extend(allowed_mcp_tools(flavor)); all.join(",") @@ -1287,6 +1305,13 @@ pub fn builtin_tools_arg() -> String { ALLOWED_BUILTIN_TOOLS.join(",") } +/// Where the NixOS module writes the per-agent Bash command allow-list +/// (see `nix/templates/harness-base.nix`). Contains a JSON array of +/// command-pattern strings like `["git *", "ls *"]`. Empty array = +/// wholesale `Bash` approval (the default). Non-empty = one +/// `Bash(pattern)` entry per item in `--allowedTools`. +const BASH_ALLOW_PATH: &str = "/etc/hyperhive/bash-allow.json"; + /// 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 @@ -1357,9 +1382,26 @@ fn default_allowed_tools() -> Vec { vec!["*".to_owned()] } -/// Read + parse the extra-MCP spec. Returns an empty map on missing / -/// unparsable file (the agent has none configured, or the file is -/// malformed — both cases degrade to "no extra servers"). +/// Read + parse the Bash command allow-list. Returns an empty vec when +/// the file is missing or unparsable (degrade to wholesale `Bash` +/// approval — same as the pre-feature behaviour). +fn load_bash_allow() -> Vec { + let Ok(raw) = std::fs::read_to_string(BASH_ALLOW_PATH) else { + return Vec::new(); + }; + serde_json::from_str::>(&raw).unwrap_or_else(|e| { + tracing::warn!( + path = BASH_ALLOW_PATH, + error = ?e, + "bash-allow list parse failed; falling back to wholesale Bash approval", + ); + Vec::new() + }) +} + +/// Read + parse the extra-MCP spec. Returns an empty map when +/// the file is missing or unparsable (the agent has none configured, +/// or the file is malformed — both cases degrade to "no extra servers"). fn load_extra_mcp() -> std::collections::BTreeMap { let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else { return std::collections::BTreeMap::new(); diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index d160192..a663eed 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -15,6 +15,29 @@ # only opts in from its own `agent.nix`. imports = [ ./weston-rdp.nix ]; + options.hyperhive.allowedBashPatterns = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "git *" + "ls *" + "cat /agents/*/state/*" + ]; + description = '' + Shell command patterns auto-approved for the `Bash` built-in tool. + Empty list (the default) grants wholesale `Bash` approval — + claude can run any shell command without a prompt. Non-empty list + replaces `Bash` in `--allowedTools` with one `Bash(pattern)` entry + per item; only commands matching a pattern are auto-approved; all + others require confirmation (which in `--print` mode means they + will not run). Use to sandbox agents to a known-safe command + vocabulary. + + Patterns use the same glob syntax claude accepts in `Bash(…)`: + `*` matches any string within a word, shell-style. + ''; + }; + options.hyperhive.allowedRecipients = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; @@ -170,6 +193,9 @@ config = { environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; + environment.etc."hyperhive/bash-allow.json".text = + builtins.toJSON config.hyperhive.allowedBashPatterns; + environment.etc."hyperhive/send-allow.json".text = builtins.toJSON config.hyperhive.allowedRecipients;