add allowedBashPatterns NixOS option for finer-grained Bash tool approval

This commit is contained in:
damocles 2026-05-20 13:08:15 +02:00
parent c05a750409
commit 3c672ed6b2
3 changed files with 88 additions and 10 deletions

View file

@ -276,11 +276,21 @@ status hint moved to the wake prompt + UI header.
### Tool whitelist (`mcp::ALLOWED_BUILTIN_TOOLS`) ### Tool whitelist (`mcp::ALLOWED_BUILTIN_TOOLS`)
- Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `Read`, - Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `Read`, `Write`.
`TodoWrite`, `Write`.
- Denied by omission: `WebFetch`, `WebSearch`, `Task`, - Denied by omission: `WebFetch`, `WebSearch`, `Task`,
`NotebookEdit`. `NotebookEdit`, `TodoWrite`.
- Allowed MCP tools: as listed above per flavor. - Allowed MCP tools: as listed above per flavor.
`Bash` is on the allow-list pending a finer-grained pattern allow-list By default `Bash` is approved wholesale — any shell command runs
(`Bash(git *)`-style) — see [issue #21](http://localhost:3000/hyperhive/hyperhive/issues/21). 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.

View file

@ -1269,12 +1269,30 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
} }
/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers /// 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] #[must_use]
pub fn allowed_tools_arg(flavor: Flavor) -> String { pub fn allowed_tools_arg(flavor: Flavor) -> String {
let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS
.iter() .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(); .collect();
all.extend(allowed_mcp_tools(flavor)); all.extend(allowed_mcp_tools(flavor));
all.join(",") all.join(",")
@ -1287,6 +1305,13 @@ pub fn builtin_tools_arg() -> String {
ALLOWED_BUILTIN_TOOLS.join(",") 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 /// Where the NixOS module writes the per-agent extra-MCP spec (see
/// `nix/templates/harness-base.nix`). Each entry becomes an additional /// `nix/templates/harness-base.nix`). Each entry becomes an additional
/// `mcpServers.<key>` block in the rendered claude config + a /// `mcpServers.<key>` block in the rendered claude config + a
@ -1357,9 +1382,26 @@ fn default_allowed_tools() -> Vec<String> {
vec!["*".to_owned()] vec!["*".to_owned()]
} }
/// Read + parse the extra-MCP spec. Returns an empty map on missing / /// Read + parse the Bash command allow-list. Returns an empty vec when
/// unparsable file (the agent has none configured, or the file is /// the file is missing or unparsable (degrade to wholesale `Bash`
/// malformed — both cases degrade to "no extra servers"). /// approval — same as the pre-feature behaviour).
fn load_bash_allow() -> Vec<String> {
let Ok(raw) = std::fs::read_to_string(BASH_ALLOW_PATH) else {
return Vec::new();
};
serde_json::from_str::<Vec<String>>(&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<String, ExtraMcpServer> { fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> {
let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else { let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else {
return std::collections::BTreeMap::new(); return std::collections::BTreeMap::new();

View file

@ -15,6 +15,29 @@
# only opts in from its own `agent.nix`. # only opts in from its own `agent.nix`.
imports = [ ./weston-rdp.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 { options.hyperhive.allowedRecipients = lib.mkOption {
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
default = [ ]; default = [ ];
@ -170,6 +193,9 @@
config = { config = {
environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; 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 = environment.etc."hyperhive/send-allow.json".text =
builtins.toJSON config.hyperhive.allowedRecipients; builtins.toJSON config.hyperhive.allowedRecipients;