From 4eae796b7eeac4b6f04cb7669a905650b0828e3f Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Mon, 19 Jan 2026 13:00:46 +0100 Subject: [PATCH 1/6] Add approval allow rules --- src-tauri/src/bin/codex_monitor_daemon.rs | 75 ++++++--- src-tauri/src/codex.rs | 43 +++++ src-tauri/src/codex_home.rs | 46 ++++++ src-tauri/src/lib.rs | 3 + src-tauri/src/rules.rs | 110 +++++++++++++ src-tauri/src/workspaces.rs | 23 +-- src/App.tsx | 5 +- .../app/components/ApprovalToasts.tsx | 13 ++ src/features/layout/hooks/useLayoutNodes.tsx | 5 + src/features/threads/hooks/useThreads.ts | 68 ++++++++ src/services/tauri.ts | 7 + src/styles/approval-toasts.css | 5 + src/utils/approvalRules.ts | 147 ++++++++++++++++++ 13 files changed, 511 insertions(+), 39 deletions(-) create mode 100644 src-tauri/src/codex_home.rs create mode 100644 src-tauri/src/rules.rs create mode 100644 src/utils/approvalRules.ts diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 67076dc84..21ffa92ac 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1,7 +1,11 @@ #[path = "../backend/mod.rs"] mod backend; +#[path = "../codex_home.rs"] +mod codex_home; #[path = "../codex_config.rs"] mod codex_config; +#[path = "../rules.rs"] +mod rules; #[path = "../storage.rs"] mod storage; #[path = "../types.rs"] @@ -147,7 +151,7 @@ impl DaemonState { settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, None); + let codex_home = codex_home::resolve_workspace_codex_home(&entry, None); let session = spawn_workspace_session( entry.clone(), default_bin, @@ -250,7 +254,7 @@ impl DaemonState { settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, Some(&parent_entry.path)); + let codex_home = codex_home::resolve_workspace_codex_home(&entry, Some(&parent_entry.path)); let session = spawn_workspace_session( entry.clone(), default_bin, @@ -485,7 +489,7 @@ impl DaemonState { } else { None }; - let codex_home = resolve_codex_home(&entry, parent_path.as_deref()); + let codex_home = codex_home::resolve_workspace_codex_home(&entry, parent_path.as_deref()); let session = spawn_workspace_session( entry, default_bin, @@ -708,6 +712,46 @@ impl DaemonState { session.send_response(request_id, result).await?; Ok(json!({ "ok": true })) } + + async fn remember_approval_rule( + &self, + workspace_id: String, + command: Vec, + ) -> Result { + let command = command + .into_iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect::>(); + if command.is_empty() { + return Err("empty command".to_string()); + } + + let (entry, parent_path) = { + let workspaces = self.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let parent_path = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .map(|parent| parent.path.clone()); + (entry, parent_path) + }; + + let codex_home = codex_home::resolve_workspace_codex_home(&entry, parent_path.as_deref()) + .or_else(codex_home::resolve_default_codex_home) + .ok_or("Unable to resolve CODEX_HOME".to_string())?; + let rules_path = rules::default_rules_path(&codex_home); + rules::append_prefix_rule(&rules_path, &command)?; + + Ok(json!({ + "ok": true, + "rulesPath": rules_path, + })) + } } fn sort_workspaces(workspaces: &mut [WorkspaceInfo]) { @@ -721,22 +765,6 @@ fn sort_workspaces(workspaces: &mut [WorkspaceInfo]) { }); } -fn resolve_codex_home(entry: &WorkspaceEntry, parent_path: Option<&str>) -> Option { - if entry.kind.is_worktree() { - if let Some(parent_path) = parent_path { - let legacy_home = PathBuf::from(parent_path).join(".codexmonitor"); - if legacy_home.is_dir() { - return Some(legacy_home); - } - } - } - let legacy_home = PathBuf::from(&entry.path).join(".codexmonitor"); - if legacy_home.is_dir() { - return Some(legacy_home); - } - None -} - fn should_skip_dir(name: &str) -> bool { matches!( name, @@ -1079,6 +1107,10 @@ fn parse_optional_string_array(value: &Value, key: &str) -> Option> } } +fn parse_string_array(value: &Value, key: &str) -> Result, String> { + parse_optional_string_array(value, key).ok_or_else(|| format!("missing `{key}`")) +} + fn parse_optional_value(value: &Value, key: &str) -> Option { match value { Value::Object(map) => map.get(key).cloned(), @@ -1259,6 +1291,11 @@ async fn handle_rpc_request( .respond_to_server_request(workspace_id, request_id, result) .await } + "remember_approval_rule" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + let command = parse_string_array(¶ms, "command")?; + state.remember_approval_rule(workspace_id, command).await + } _ => Err(format!("unknown method: {method}")), } } diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index ee6b1462d..a09033e33 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -13,7 +13,9 @@ use crate::backend::app_server::{ build_codex_command_with_bin, build_codex_path_env, check_codex_installation, spawn_workspace_session as spawn_workspace_session_inner, }; +use crate::codex_home::{resolve_default_codex_home, resolve_workspace_codex_home}; use crate::event_sink::TauriEventSink; +use crate::rules; use crate::state::AppState; use crate::types::WorkspaceEntry; @@ -378,3 +380,44 @@ pub(crate) async fn respond_to_server_request( .ok_or("workspace not connected")?; session.send_response(request_id, result).await } + +#[tauri::command] +pub(crate) async fn remember_approval_rule( + workspace_id: String, + command: Vec, + state: State<'_, AppState>, +) -> Result { + let command = command + .into_iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect::>(); + if command.is_empty() { + return Err("empty command".to_string()); + } + + let (entry, parent_path) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let parent_path = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .map(|parent| parent.path.clone()); + (entry, parent_path) + }; + + let codex_home = resolve_workspace_codex_home(&entry, parent_path.as_deref()) + .or_else(resolve_default_codex_home) + .ok_or("Unable to resolve CODEX_HOME".to_string())?; + let rules_path = rules::default_rules_path(&codex_home); + rules::append_prefix_rule(&rules_path, &command)?; + + Ok(json!({ + "ok": true, + "rulesPath": rules_path, + })) +} diff --git a/src-tauri/src/codex_home.rs b/src-tauri/src/codex_home.rs new file mode 100644 index 000000000..289936bda --- /dev/null +++ b/src-tauri/src/codex_home.rs @@ -0,0 +1,46 @@ +use std::env; +use std::path::PathBuf; + +use crate::types::WorkspaceEntry; + +pub(crate) fn resolve_workspace_codex_home( + entry: &WorkspaceEntry, + parent_path: Option<&str>, +) -> Option { + if entry.kind.is_worktree() { + if let Some(parent_path) = parent_path { + let legacy_home = PathBuf::from(parent_path).join(".codexmonitor"); + if legacy_home.is_dir() { + return Some(legacy_home); + } + } + } + let legacy_home = PathBuf::from(&entry.path).join(".codexmonitor"); + if legacy_home.is_dir() { + return Some(legacy_home); + } + None +} + +pub(crate) fn resolve_default_codex_home() -> Option { + if let Ok(value) = env::var("CODEX_HOME") { + if !value.trim().is_empty() { + return Some(PathBuf::from(value.trim())); + } + } + resolve_home_dir().map(|home| home.join(".codex")) +} + +fn resolve_home_dir() -> Option { + if let Ok(value) = env::var("HOME") { + if !value.trim().is_empty() { + return Some(PathBuf::from(value)); + } + } + if let Ok(value) = env::var("USERPROFILE") { + if !value.trim().is_empty() { + return Some(PathBuf::from(value)); + } + } + None +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c5ebffabd..e3264a869 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,12 +3,14 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; mod backend; mod codex; +mod codex_home; mod codex_config; mod dictation; mod event_sink; mod git; mod git_utils; mod prompts; +mod rules; mod settings; mod state; mod terminal; @@ -231,6 +233,7 @@ pub fn run() { codex::turn_interrupt, codex::start_review, codex::respond_to_server_request, + codex::remember_approval_rule, codex::resume_thread, codex::list_threads, codex::archive_thread, diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs new file mode 100644 index 000000000..756209da6 --- /dev/null +++ b/src-tauri/src/rules.rs @@ -0,0 +1,110 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +const RULES_DIR: &str = "rules"; +const DEFAULT_RULES_FILE: &str = "default.rules"; + +pub(crate) fn default_rules_path(codex_home: &Path) -> PathBuf { + codex_home.join(RULES_DIR).join(DEFAULT_RULES_FILE) +} + +pub(crate) fn append_prefix_rule(path: &Path, pattern: &[String]) -> Result<(), String> { + if pattern.is_empty() { + return Err("empty command pattern".to_string()); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + + let existing = fs::read_to_string(path).unwrap_or_default(); + if rule_already_present(&existing, pattern) { + return Ok(()); + } + let mut updated = existing; + + if !updated.is_empty() && !updated.ends_with('\n') { + updated.push('\n'); + } + if !updated.is_empty() { + updated.push('\n'); + } + + let rule = format_prefix_rule(pattern); + updated.push_str(&rule); + + if !updated.ends_with('\n') { + updated.push('\n'); + } + + fs::write(path, updated).map_err(|err| err.to_string()) +} + +fn format_prefix_rule(pattern: &[String]) -> String { + let items = format_pattern_list(pattern); + format!( + "prefix_rule(\n pattern = [{items}],\n decision = \"allow\",\n)\n" + ) +} + +fn format_pattern_list(pattern: &[String]) -> String { + pattern + .iter() + .map(|item| format!("\"{}\"", escape_string(item))) + .collect::>() + .join(", ") +} + +fn rule_already_present(contents: &str, pattern: &[String]) -> bool { + let target_pattern = normalize_rule_value(&format!("[{}]", format_pattern_list(pattern))); + let mut in_rule = false; + let mut pattern_matches = false; + let mut decision_allows = false; + + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("prefix_rule(") { + in_rule = true; + pattern_matches = false; + decision_allows = false; + continue; + } + if !in_rule { + continue; + } + if trimmed.starts_with("pattern") { + if let Some((_, value)) = trimmed.split_once('=') { + let candidate = value.trim().trim_end_matches(','); + if normalize_rule_value(candidate) == target_pattern { + pattern_matches = true; + } + } + } else if trimmed.starts_with("decision") { + if let Some((_, value)) = trimmed.split_once('=') { + let candidate = value.trim().trim_end_matches(','); + if candidate.contains("\"allow\"") || candidate.contains("'allow'") { + decision_allows = true; + } + } + } else if trimmed.starts_with(')') { + if pattern_matches && decision_allows { + return true; + } + in_rule = false; + } + } + false +} + +fn normalize_rule_value(value: &str) -> String { + value.chars().filter(|ch| !ch.is_whitespace()).collect() +} + +fn escape_string(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('\"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 3b1e662d0..b869d418c 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -7,6 +7,7 @@ use tokio::process::Command; use uuid::Uuid; use crate::codex::spawn_workspace_session; +use crate::codex_home::resolve_workspace_codex_home; use crate::state::AppState; use crate::storage::write_workspaces; use crate::types::{ @@ -14,22 +15,6 @@ use crate::types::{ }; use crate::utils::normalize_git_path; -fn resolve_codex_home(entry: &WorkspaceEntry, parent_path: Option<&str>) -> Option { - if entry.kind.is_worktree() { - if let Some(parent_path) = parent_path { - let legacy_home = PathBuf::from(parent_path).join(".codexmonitor"); - if legacy_home.is_dir() { - return Some(legacy_home); - } - } - } - let legacy_home = PathBuf::from(&entry.path).join(".codexmonitor"); - if legacy_home.is_dir() { - return Some(legacy_home); - } - None -} - fn should_skip_dir(name: &str) -> bool { matches!( name, @@ -224,7 +209,7 @@ pub(crate) async fn add_workspace( let settings = state.app_settings.lock().await; settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, None); + let codex_home = resolve_workspace_codex_home(&entry, None); let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?; { let mut workspaces = state.workspaces.lock().await; @@ -320,7 +305,7 @@ pub(crate) async fn add_worktree( let settings = state.app_settings.lock().await; settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, Some(&parent_entry.path)); + let codex_home = resolve_workspace_codex_home(&entry, Some(&parent_entry.path)); let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?; { let mut workspaces = state.workspaces.lock().await; @@ -543,7 +528,7 @@ pub(crate) async fn connect_workspace( let settings = state.app_settings.lock().await; settings.codex_bin.clone() }; - let codex_home = resolve_codex_home(&entry, parent_path.as_deref()); + let codex_home = resolve_workspace_codex_home(&entry, parent_path.as_deref()); let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?; state.sessions.lock().await.insert(entry.id, session); Ok(()) diff --git a/src/App.tsx b/src/App.tsx index 099ddf4ce..d7bcf506f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -605,7 +605,8 @@ function MainApp() { sendUserMessage, sendUserMessageToThread, startReview, - handleApprovalDecision + handleApprovalDecision, + handleApprovalRemember } = useThreads({ activeWorkspace, onWorkspaceConnected: markWorkspaceConnected, @@ -1145,6 +1146,8 @@ function MainApp() { activeRateLimits, approvals, handleApprovalDecision, + handleApprovalRemember, + themePreference: resolvedTheme, onOpenSettings: () => handleOpenSettings(), onOpenDictationSettings: () => handleOpenSettings("dictation"), onOpenDebug: handleDebugClick, diff --git a/src/features/app/components/ApprovalToasts.tsx b/src/features/app/components/ApprovalToasts.tsx index b03cae81d..6891e9f54 100644 --- a/src/features/app/components/ApprovalToasts.tsx +++ b/src/features/app/components/ApprovalToasts.tsx @@ -1,16 +1,19 @@ import { useEffect, useMemo } from "react"; import type { ApprovalRequest, WorkspaceInfo } from "../../../types"; +import { getApprovalCommandInfo } from "../../../utils/approvalRules"; type ApprovalToastsProps = { approvals: ApprovalRequest[]; workspaces: WorkspaceInfo[]; onDecision: (request: ApprovalRequest, decision: "accept" | "decline") => void; + onRemember?: (request: ApprovalRequest, command: string[]) => void; }; export function ApprovalToasts({ approvals, workspaces, onDecision, + onRemember, }: ApprovalToastsProps) { const workspaceLabels = useMemo( () => new Map(workspaces.map((workspace) => [workspace.id, workspace.name])), @@ -82,6 +85,7 @@ export function ApprovalToasts({ {approvals.map((request) => { const workspaceName = workspaceLabels.get(request.workspace_id); const params = request.params ?? {}; + const commandInfo = getApprovalCommandInfo(params); const entries = Object.entries(params); return (
Decline + {commandInfo && onRemember ? ( + + ) : null}