feat(security): add approval modes with CLI controls

This commit is contained in:
2025-10-26 02:31:03 +02:00
parent 9aa8722ec3
commit c92e07b866
6 changed files with 211 additions and 5 deletions

View File

@@ -1,3 +1,7 @@
# Agents Upgrade Plan # Agents Upgrade Plan
All tracked items completed. - feat: implement resumable command queue, automatic thought summaries, and queued execution controls to match Codex CLI session management and Claude Codes scripted workflows
- feat: add first-class prompt, agent, and sub-agent configuration via `.owlen/agents` plus reusable prompt libraries, mirroring Codex custom prompts and Claudes configurable agents
- feat: deliver official VS Code extension and browser workspace so Owlen runs alongside Codexs IDE plugin and Claude Codes app-based surfaces
- feat: support multimodal inputs (images, rich artifacts) and preview panes so non-text context matches Codex CLI image handling and Claude Codes artifact outputs
- feat: integrate repository automation (GitHub PR review, commit templating, Claude SDK-style automation APIs) to reach parity with Codex CLIs GitHub integration and Claude Codes CLI/SDK automation

View File

@@ -2,4 +2,5 @@
pub mod cloud; pub mod cloud;
pub mod providers; pub mod providers;
pub mod security;
pub mod tools; pub mod tools;

View File

@@ -0,0 +1,61 @@
use anyhow::Result;
use clap::{Subcommand, ValueEnum};
use owlen_core::config::ApprovalMode;
use owlen_tui::config as tui_config;
/// Security-related configuration commands.
#[derive(Debug, Subcommand)]
pub enum SecurityCommand {
/// Display the current approval mode.
Show,
/// Set the approval mode (auto, read-only, plan-first).
Approval {
/// Approval policy to apply to new sessions.
#[arg(value_enum)]
mode: ApprovalModeArg,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ApprovalModeArg {
Auto,
#[clap(name = "read-only")]
ReadOnly,
#[clap(name = "plan-first")]
PlanFirst,
}
impl From<ApprovalModeArg> for ApprovalMode {
fn from(value: ApprovalModeArg) -> Self {
match value {
ApprovalModeArg::Auto => ApprovalMode::Auto,
ApprovalModeArg::ReadOnly => ApprovalMode::ReadOnly,
ApprovalModeArg::PlanFirst => ApprovalMode::PlanFirst,
}
}
}
pub fn run_security_command(command: SecurityCommand) -> Result<()> {
match command {
SecurityCommand::Show => show_approval_mode(),
SecurityCommand::Approval { mode } => set_approval_mode(mode.into()),
}
}
fn show_approval_mode() -> Result<()> {
let config = tui_config::try_load_config().unwrap_or_default();
println!(
"Current approval mode: {}",
config.security.approval_mode.as_str()
);
Ok(())
}
fn set_approval_mode(mode: ApprovalMode) -> Result<()> {
let mut config = tui_config::try_load_config().unwrap_or_default();
config.security.approval_mode = mode;
config.validate()?;
tui_config::save_config(&config)?;
println!("Set approval mode to {}.", mode.as_str());
Ok(())
}

View File

@@ -11,6 +11,7 @@ use clap::{Parser, Subcommand};
use commands::{ use commands::{
cloud::{CloudCommand, run_cloud_command}, cloud::{CloudCommand, run_cloud_command},
providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command}, providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command},
security::{SecurityCommand, run_security_command},
tools::{ToolsCommand, run_tools_command}, tools::{ToolsCommand, run_tools_command},
}; };
use mcp::{McpCommand, run_mcp_command}; use mcp::{McpCommand, run_mcp_command};
@@ -60,6 +61,9 @@ enum OwlenCommand {
/// Manage MCP tool presets /// Manage MCP tool presets
#[command(subcommand)] #[command(subcommand)]
Tools(ToolsCommand), Tools(ToolsCommand),
/// Configure security and approval policies
#[command(subcommand)]
Security(SecurityCommand),
/// Show manual steps for updating Owlen to the latest revision /// Show manual steps for updating Owlen to the latest revision
Upgrade, Upgrade,
} }
@@ -86,6 +90,7 @@ async fn run_command(command: OwlenCommand) -> Result<()> {
OwlenCommand::Models(args) => run_models_command(args).await, OwlenCommand::Models(args) => run_models_command(args).await,
OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd), OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd),
OwlenCommand::Tools(tools_cmd) => run_tools_command(tools_cmd), OwlenCommand::Tools(tools_cmd) => run_tools_command(tools_cmd),
OwlenCommand::Security(sec_cmd) => run_security_command(sec_cmd),
OwlenCommand::Upgrade => { OwlenCommand::Upgrade => {
println!( println!(
"To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force" "To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force"

View File

@@ -1735,6 +1735,25 @@ impl Default for PrivacySettings {
} }
/// Security settings that constrain tool execution /// Security settings that constrain tool execution
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ApprovalMode {
#[default]
Auto,
ReadOnly,
PlanFirst,
}
impl ApprovalMode {
pub fn as_str(self) -> &'static str {
match self {
ApprovalMode::Auto => "auto",
ApprovalMode::ReadOnly => "read-only",
ApprovalMode::PlanFirst => "plan-first",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecuritySettings { pub struct SecuritySettings {
#[serde(default = "SecuritySettings::default_enable_sandboxing")] #[serde(default = "SecuritySettings::default_enable_sandboxing")]
@@ -1745,6 +1764,8 @@ pub struct SecuritySettings {
pub max_memory_mb: u64, pub max_memory_mb: u64,
#[serde(default = "SecuritySettings::default_allowed_tools")] #[serde(default = "SecuritySettings::default_allowed_tools")]
pub allowed_tools: Vec<String>, pub allowed_tools: Vec<String>,
#[serde(default)]
pub approval_mode: ApprovalMode,
} }
impl SecuritySettings { impl SecuritySettings {
@@ -1769,6 +1790,10 @@ impl SecuritySettings {
"file_delete".to_string(), "file_delete".to_string(),
] ]
} }
pub fn approval_mode(&self) -> ApprovalMode {
self.approval_mode
}
} }
impl Default for SecuritySettings { impl Default for SecuritySettings {
@@ -1778,6 +1803,7 @@ impl Default for SecuritySettings {
sandbox_timeout_seconds: Self::default_timeout(), sandbox_timeout_seconds: Self::default_timeout(),
max_memory_mb: Self::default_max_memory(), max_memory_mb: Self::default_max_memory(),
allowed_tools: Self::default_allowed_tools(), allowed_tools: Self::default_allowed_tools(),
approval_mode: ApprovalMode::default(),
} }
} }
} }

View File

@@ -1,5 +1,5 @@
use crate::config::{ use crate::config::{
ChatSettings, CompressionStrategy, Config, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, ApprovalMode, ChatSettings, CompressionStrategy, Config, LEGACY_OLLAMA_CLOUD_API_KEY_ENV,
LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, McpResourceConfig, McpServerConfig, OLLAMA_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, McpResourceConfig, McpServerConfig, OLLAMA_API_KEY_ENV,
OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_BASE_URL,
}; };
@@ -535,6 +535,9 @@ pub struct SessionController {
credential_manager: Option<Arc<CredentialManager>>, credential_manager: Option<Arc<CredentialManager>>,
ui: Arc<dyn UiController>, ui: Arc<dyn UiController>,
enable_code_tools: bool, enable_code_tools: bool,
approval_mode: ApprovalMode,
plan_confirmed: bool,
plan_instruction_inserted: bool,
current_mode: Mode, current_mode: Mode,
missing_oauth_servers: Vec<String>, missing_oauth_servers: Vec<String>,
event_tx: Option<UnboundedSender<ControllerEvent>>, event_tx: Option<UnboundedSender<ControllerEvent>>,
@@ -565,6 +568,12 @@ async fn build_tools(
let active_provider_id = config_guard.general.default_provider.clone(); let active_provider_id = config_guard.general.default_provider.clone();
let approval_mode = config_guard.security.approval_mode;
let read_only = matches!(approval_mode, ApprovalMode::ReadOnly);
if read_only {
debug!("Approval mode 'read-only' active: suppressing write/delete/code-exec tools.");
}
let web_search_settings = if config_guard let web_search_settings = if config_guard
.security .security
.allowed_tools .allowed_tools
@@ -603,6 +612,7 @@ async fn build_tools(
} }
if enable_code_tools if enable_code_tools
&& !read_only
&& config_guard && config_guard
.security .security
.allowed_tools .allowed_tools
@@ -622,6 +632,7 @@ async fn build_tools(
.allowed_tools .allowed_tools
.iter() .iter()
.any(|t| t == "file_write") .any(|t| t == "file_write")
&& !read_only
{ {
registry.register(ResourcesWriteTool)?; registry.register(ResourcesWriteTool)?;
} }
@@ -630,6 +641,7 @@ async fn build_tools(
.allowed_tools .allowed_tools
.iter() .iter()
.any(|t| t == "file_delete") .any(|t| t == "file_delete")
&& !read_only
{ {
registry.register(ResourcesDeleteTool)?; registry.register(ResourcesDeleteTool)?;
} }
@@ -798,6 +810,17 @@ impl SessionController {
Arc::new(Mutex::new(ConsentManager::new())) Arc::new(Mutex::new(ConsentManager::new()))
}; };
let approval_mode = config_guard.security.approval_mode;
let requested_code_tools = enable_code_tools;
let enable_code_tools = if matches!(approval_mode, ApprovalMode::ReadOnly) {
if requested_code_tools {
warn!("Read-only approval mode disables code tools.");
}
false
} else {
requested_code_tools
};
let conversation = ConversationManager::with_history_capacity( let conversation = ConversationManager::with_history_capacity(
model, model,
config_guard.storage.max_saved_sessions, config_guard.storage.max_saved_sessions,
@@ -860,7 +883,7 @@ impl SessionController {
}; };
let usage_ledger = Arc::new(TokioMutex::new(usage_ledger_instance)); let usage_ledger = Arc::new(TokioMutex::new(usage_ledger_instance));
Ok(Self { let mut controller = Self {
provider, provider,
conversation, conversation,
model_manager, model_manager,
@@ -878,6 +901,9 @@ impl SessionController {
credential_manager, credential_manager,
ui, ui,
enable_code_tools, enable_code_tools,
approval_mode,
plan_confirmed: true,
plan_instruction_inserted: false,
current_mode: initial_mode, current_mode: initial_mode,
missing_oauth_servers, missing_oauth_servers,
event_tx, event_tx,
@@ -885,7 +911,11 @@ impl SessionController {
stream_states: HashMap::new(), stream_states: HashMap::new(),
usage_ledger, usage_ledger,
last_compression: None, last_compression: None,
}) };
controller.reset_plan_state();
Ok(controller)
} }
pub fn conversation(&self) -> &Conversation { pub fn conversation(&self) -> &Conversation {
@@ -1669,6 +1699,11 @@ impl SessionController {
} }
pub async fn set_code_tools_enabled(&mut self, enabled: bool) -> Result<()> { pub async fn set_code_tools_enabled(&mut self, enabled: bool) -> Result<()> {
if matches!(self.approval_mode, ApprovalMode::ReadOnly) && enabled {
warn!("Read-only approval mode prevents enabling code tools; ignoring request.");
self.enable_code_tools = false;
return Ok(());
}
if self.enable_code_tools == enabled { if self.enable_code_tools == enabled {
return Ok(()); return Ok(());
} }
@@ -1677,11 +1712,45 @@ impl SessionController {
self.rebuild_tools().await self.rebuild_tools().await
} }
fn reset_plan_state(&mut self) {
self.plan_instruction_inserted = false;
match self.approval_mode {
ApprovalMode::PlanFirst => {
self.plan_confirmed = false;
self.ensure_plan_prompt();
}
_ => {
self.plan_confirmed = true;
}
}
}
fn ensure_plan_prompt(&mut self) {
if !matches!(self.approval_mode, ApprovalMode::PlanFirst) {
return;
}
if self.plan_instruction_inserted {
return;
}
let prompt = "You are operating in plan-first approval mode. Outline a concise plan before using any tools or modifying files, and wait for the user to send '#approve' before proceeding.";
self.conversation.push_system_message(prompt);
self.plan_instruction_inserted = true;
}
pub async fn set_operating_mode(&mut self, mode: Mode) -> Result<()> { pub async fn set_operating_mode(&mut self, mode: Mode) -> Result<()> {
if matches!(self.approval_mode, ApprovalMode::ReadOnly) && matches!(mode, Mode::Code) {
warn!(
"Read-only approval mode prevents switching to code mode; remaining in chat mode."
);
self.current_mode = Mode::Chat;
self.set_code_tools_enabled(false).await?;
return Ok(());
}
self.current_mode = mode; self.current_mode = mode;
let enable_code_tools = matches!(mode, Mode::Code); let enable_code_tools = matches!(mode, Mode::Code);
self.set_code_tools_enabled(enable_code_tools).await?; self.set_code_tools_enabled(enable_code_tools).await?;
self.mcp_client.set_mode(mode).await self.mcp_client.set_mode(self.current_mode).await
} }
pub async fn list_dir(&self, path: &str) -> Result<Vec<String>> { pub async fn list_dir(&self, path: &str) -> Result<Vec<String>> {
@@ -1887,6 +1956,36 @@ impl SessionController {
) -> Result<SessionOutcome> { ) -> Result<SessionOutcome> {
let streaming = { self.config.lock().await.general.enable_streaming || parameters.stream }; let streaming = { self.config.lock().await.general.enable_streaming || parameters.stream };
parameters.stream = streaming; parameters.stream = streaming;
if matches!(self.approval_mode, ApprovalMode::PlanFirst) {
self.ensure_plan_prompt();
let trimmed = content.trim();
let normalized = trimmed.to_ascii_lowercase();
if matches!(
normalized.as_str(),
"#approve" | ":approve" | "/approve" | "approve" | "approve plan"
) {
self.conversation.push_user_message(trimmed.to_string());
let _ = self.maybe_auto_compress().await?;
if !self.plan_confirmed {
self.plan_confirmed = true;
self.conversation
.push_system_message("Plan approved by the operator. Tool access enabled.");
}
let ack = Message::assistant(
"Plan approved. Ready to continue with execution.".to_string(),
);
self.conversation.push_message(ack.clone());
return Ok(SessionOutcome::Complete(ChatResponse {
message: ack,
usage: None,
is_streaming: false,
is_final: true,
}));
}
}
self.conversation.push_user_message(content); self.conversation.push_user_message(content);
let _ = self.maybe_auto_compress().await?; let _ = self.maybe_auto_compress().await?;
self.send_request_with_current_conversation(parameters) self.send_request_with_current_conversation(parameters)
@@ -1900,10 +1999,18 @@ impl SessionController {
let streaming = { self.config.lock().await.general.enable_streaming || parameters.stream }; let streaming = { self.config.lock().await.general.enable_streaming || parameters.stream };
parameters.stream = streaming; parameters.stream = streaming;
if matches!(self.approval_mode, ApprovalMode::PlanFirst) {
self.ensure_plan_prompt();
}
let active_model = self.conversation.active().model.clone(); let active_model = self.conversation.active().model.clone();
let registry_tools = self.tool_registry.all(); let registry_tools = self.tool_registry.all();
let mut include_tools = !registry_tools.is_empty(); let mut include_tools = !registry_tools.is_empty();
if matches!(self.approval_mode, ApprovalMode::PlanFirst) && !self.plan_confirmed {
include_tools = false;
}
if include_tools { if include_tools {
let cached_support = self.model_manager.select(&active_model).await; let cached_support = self.model_manager.select(&active_model).await;
let supports_tools = match cached_support { let supports_tools = match cached_support {
@@ -2192,11 +2299,13 @@ impl SessionController {
pub fn start_new_conversation(&mut self, model: Option<String>, name: Option<String>) { pub fn start_new_conversation(&mut self, model: Option<String>, name: Option<String>) {
self.conversation.start_new(model, name); self.conversation.start_new(model, name);
self.stream_states.clear(); self.stream_states.clear();
self.reset_plan_state();
} }
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.conversation.clear(); self.conversation.clear();
self.stream_states.clear(); self.stream_states.clear();
self.reset_plan_state();
} }
pub async fn generate_conversation_description(&self) -> Result<String> { pub async fn generate_conversation_description(&self) -> Result<String> {