feat(security): add approval modes with CLI controls
This commit is contained in:
@@ -2,4 +2,5 @@
|
||||
|
||||
pub mod cloud;
|
||||
pub mod providers;
|
||||
pub mod security;
|
||||
pub mod tools;
|
||||
|
||||
61
crates/owlen-cli/src/commands/security.rs
Normal file
61
crates/owlen-cli/src/commands/security.rs
Normal 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(())
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use clap::{Parser, Subcommand};
|
||||
use commands::{
|
||||
cloud::{CloudCommand, run_cloud_command},
|
||||
providers::{ModelsArgs, ProvidersCommand, run_models_command, run_providers_command},
|
||||
security::{SecurityCommand, run_security_command},
|
||||
tools::{ToolsCommand, run_tools_command},
|
||||
};
|
||||
use mcp::{McpCommand, run_mcp_command};
|
||||
@@ -60,6 +61,9 @@ enum OwlenCommand {
|
||||
/// Manage MCP tool presets
|
||||
#[command(subcommand)]
|
||||
Tools(ToolsCommand),
|
||||
/// Configure security and approval policies
|
||||
#[command(subcommand)]
|
||||
Security(SecurityCommand),
|
||||
/// Show manual steps for updating Owlen to the latest revision
|
||||
Upgrade,
|
||||
}
|
||||
@@ -86,6 +90,7 @@ async fn run_command(command: OwlenCommand) -> Result<()> {
|
||||
OwlenCommand::Models(args) => run_models_command(args).await,
|
||||
OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd),
|
||||
OwlenCommand::Tools(tools_cmd) => run_tools_command(tools_cmd),
|
||||
OwlenCommand::Security(sec_cmd) => run_security_command(sec_cmd),
|
||||
OwlenCommand::Upgrade => {
|
||||
println!(
|
||||
"To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force"
|
||||
|
||||
@@ -1735,6 +1735,25 @@ impl Default for PrivacySettings {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct SecuritySettings {
|
||||
#[serde(default = "SecuritySettings::default_enable_sandboxing")]
|
||||
@@ -1745,6 +1764,8 @@ pub struct SecuritySettings {
|
||||
pub max_memory_mb: u64,
|
||||
#[serde(default = "SecuritySettings::default_allowed_tools")]
|
||||
pub allowed_tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub approval_mode: ApprovalMode,
|
||||
}
|
||||
|
||||
impl SecuritySettings {
|
||||
@@ -1769,6 +1790,10 @@ impl SecuritySettings {
|
||||
"file_delete".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn approval_mode(&self) -> ApprovalMode {
|
||||
self.approval_mode
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SecuritySettings {
|
||||
@@ -1778,6 +1803,7 @@ impl Default for SecuritySettings {
|
||||
sandbox_timeout_seconds: Self::default_timeout(),
|
||||
max_memory_mb: Self::default_max_memory(),
|
||||
allowed_tools: Self::default_allowed_tools(),
|
||||
approval_mode: ApprovalMode::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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,
|
||||
OLLAMA_CLOUD_BASE_URL,
|
||||
};
|
||||
@@ -535,6 +535,9 @@ pub struct SessionController {
|
||||
credential_manager: Option<Arc<CredentialManager>>,
|
||||
ui: Arc<dyn UiController>,
|
||||
enable_code_tools: bool,
|
||||
approval_mode: ApprovalMode,
|
||||
plan_confirmed: bool,
|
||||
plan_instruction_inserted: bool,
|
||||
current_mode: Mode,
|
||||
missing_oauth_servers: Vec<String>,
|
||||
event_tx: Option<UnboundedSender<ControllerEvent>>,
|
||||
@@ -565,6 +568,12 @@ async fn build_tools(
|
||||
|
||||
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
|
||||
.security
|
||||
.allowed_tools
|
||||
@@ -603,6 +612,7 @@ async fn build_tools(
|
||||
}
|
||||
|
||||
if enable_code_tools
|
||||
&& !read_only
|
||||
&& config_guard
|
||||
.security
|
||||
.allowed_tools
|
||||
@@ -622,6 +632,7 @@ async fn build_tools(
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|t| t == "file_write")
|
||||
&& !read_only
|
||||
{
|
||||
registry.register(ResourcesWriteTool)?;
|
||||
}
|
||||
@@ -630,6 +641,7 @@ async fn build_tools(
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|t| t == "file_delete")
|
||||
&& !read_only
|
||||
{
|
||||
registry.register(ResourcesDeleteTool)?;
|
||||
}
|
||||
@@ -798,6 +810,17 @@ impl SessionController {
|
||||
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(
|
||||
model,
|
||||
config_guard.storage.max_saved_sessions,
|
||||
@@ -860,7 +883,7 @@ impl SessionController {
|
||||
};
|
||||
let usage_ledger = Arc::new(TokioMutex::new(usage_ledger_instance));
|
||||
|
||||
Ok(Self {
|
||||
let mut controller = Self {
|
||||
provider,
|
||||
conversation,
|
||||
model_manager,
|
||||
@@ -878,6 +901,9 @@ impl SessionController {
|
||||
credential_manager,
|
||||
ui,
|
||||
enable_code_tools,
|
||||
approval_mode,
|
||||
plan_confirmed: true,
|
||||
plan_instruction_inserted: false,
|
||||
current_mode: initial_mode,
|
||||
missing_oauth_servers,
|
||||
event_tx,
|
||||
@@ -885,7 +911,11 @@ impl SessionController {
|
||||
stream_states: HashMap::new(),
|
||||
usage_ledger,
|
||||
last_compression: None,
|
||||
})
|
||||
};
|
||||
|
||||
controller.reset_plan_state();
|
||||
|
||||
Ok(controller)
|
||||
}
|
||||
|
||||
pub fn conversation(&self) -> &Conversation {
|
||||
@@ -1669,6 +1699,11 @@ impl SessionController {
|
||||
}
|
||||
|
||||
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 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1677,11 +1712,45 @@ impl SessionController {
|
||||
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<()> {
|
||||
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;
|
||||
let enable_code_tools = matches!(mode, Mode::Code);
|
||||
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>> {
|
||||
@@ -1887,6 +1956,36 @@ impl SessionController {
|
||||
) -> Result<SessionOutcome> {
|
||||
let streaming = { self.config.lock().await.general.enable_streaming || parameters.stream };
|
||||
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);
|
||||
let _ = self.maybe_auto_compress().await?;
|
||||
self.send_request_with_current_conversation(parameters)
|
||||
@@ -1900,10 +1999,18 @@ impl SessionController {
|
||||
let streaming = { self.config.lock().await.general.enable_streaming || parameters.stream };
|
||||
parameters.stream = streaming;
|
||||
|
||||
if matches!(self.approval_mode, ApprovalMode::PlanFirst) {
|
||||
self.ensure_plan_prompt();
|
||||
}
|
||||
|
||||
let active_model = self.conversation.active().model.clone();
|
||||
let registry_tools = self.tool_registry.all();
|
||||
let mut include_tools = !registry_tools.is_empty();
|
||||
|
||||
if matches!(self.approval_mode, ApprovalMode::PlanFirst) && !self.plan_confirmed {
|
||||
include_tools = false;
|
||||
}
|
||||
|
||||
if include_tools {
|
||||
let cached_support = self.model_manager.select(&active_model).await;
|
||||
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>) {
|
||||
self.conversation.start_new(model, name);
|
||||
self.stream_states.clear();
|
||||
self.reset_plan_state();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.conversation.clear();
|
||||
self.stream_states.clear();
|
||||
self.reset_plan_state();
|
||||
}
|
||||
|
||||
pub async fn generate_conversation_description(&self) -> Result<String> {
|
||||
|
||||
Reference in New Issue
Block a user