fix(tui): harden cached denial notices

Persist cached-denial explanations in the transcript without shifting active tool virtual indices, and describe the matching, process-scoped cache consistently across locales.

Honor CODEWHALE_HOME for audit records, prune expired toasts throughout the queue, and cover compact, localized, isolated-audit, and parallel-tool regressions.

Signed-off-by: Nightt <[email protected]>
This commit is contained in:
Nightt committed 2026-07-16 18:16:08 +08:00
1 parent 6a2b76d121
commit e73026b166
14 files changed
+354 -63

No files matched your search

+1 -1
View File
@@ -567,7 +567,7 @@
"ApprovalChooseAction": "Enter selected option, or press y/a/d directly",
"ApprovalIntentLabel": "Intent: ",
"ApprovalMoreLines": " … (+{count} lines)",
"ApprovalAutoDeniedSession": "Auto-denied {tool}: you rejected this exact request earlier in this session. Restart CodeWhale to reconsider it.",
"ApprovalAutoDeniedSession": "Auto-denied {tool}: a matching request was denied earlier during this CodeWhale run. Restart CodeWhale to reconsider it.",
"ElevationTitleSandboxDenied": " ⚠, Sandbox Denied ",
"ElevationTitleRequired": " Sandbox Elevation Required ",
"ElevationFieldTool": " Tool: ",
+1 -1
View File
@@ -565,7 +565,7 @@
"ApprovalChooseAction": "Enter para seleccionar, o presione y/a/d directamente",
"ApprovalIntentLabel": "Intención: ",
"ApprovalMoreLines": " … (+{count} líneas)",
"ApprovalAutoDeniedSession": "Se rechazó automáticamente {tool}: ya rechazaste esta solicitud exacta en esta sesión. Reinicia CodeWhale para reconsiderarla.",
"ApprovalAutoDeniedSession": "Se rechazó automáticamente {tool}: se rechazó antes una solicitud coincidente durante esta ejecución de CodeWhale. Reinicia CodeWhale para reconsiderarla.",
"ElevationTitleSandboxDenied": " ⚠, Sandbox Denegado ",
"ElevationTitleRequired": " Elevación de Sandbox Requerida ",
"ElevationFieldTool": " Herramienta: ",
+1 -1
View File
@@ -565,7 +565,7 @@
"ApprovalChooseAction": "Enterで選択、または y/a/d を直接入力",
"ApprovalIntentLabel": "意図:",
"ApprovalMoreLines": " … (+{count} 行)",
"ApprovalAutoDeniedSession": "{tool} を自動的に拒否しました: このセッションで同じリクエストを以前に拒否しています。再検討するには CodeWhale を再起動してください。",
"ApprovalAutoDeniedSession": "{tool} を自動的に拒否しました: この CodeWhale の実行中に一致するリクエストが以前拒否されています。再検討するには CodeWhale を再起動してください。",
"ElevationTitleSandboxDenied": " ⚠, サンドボックス拒否 ",
"ElevationTitleRequired": " サンドボックス昇格 ",
"ElevationFieldTool": " ツール:",
+1 -1
View File
@@ -567,7 +567,7 @@
"ApprovalChooseAction": "Enter로 선택 항목 적용, 또는 y/a/d를 바로 누르세요",
"ApprovalIntentLabel": "의도: ",
"ApprovalMoreLines": " … (+{count}줄)",
"ApprovalAutoDeniedSession": "{tool} 자동 거부: 이 세션에서 동일한 요청을 이전에 거부했습니다. 다시 검토하려면 CodeWhale을 재시작하세요.",
"ApprovalAutoDeniedSession": "{tool} 자동 거부: 이번 CodeWhale 실행 중 일치하는 요청이 이전에 거부되었습니다. 다시 검토하려면 CodeWhale을 재시작하세요.",
"ElevationTitleSandboxDenied": " ⚠, 샌드박스 거부됨 ",
"ElevationTitleRequired": " 샌드박스 권한 상승 필요 ",
"ElevationFieldTool": " 도구: ",
+1 -1
View File
@@ -565,7 +565,7 @@
"ApprovalChooseAction": "Enter para selecionar, ou pressione y/a/d diretamente",
"ApprovalIntentLabel": "Intenção: ",
"ApprovalMoreLines": " … (+{count} linhas)",
"ApprovalAutoDeniedSession": "{tool} foi negado automaticamente: você já negou esta mesma solicitação nesta sessão. Reinicie o CodeWhale para reconsiderá-la.",
"ApprovalAutoDeniedSession": "{tool} foi negado automaticamente: uma solicitação correspondente foi negada anteriormente nesta execução do CodeWhale. Reinicie o CodeWhale para reconsiderá-la.",
"ElevationTitleSandboxDenied": " ⚠, Sandbox Negado ",
"ElevationTitleRequired": " Elevação de Sandbox Necessária ",
"ElevationFieldTool": " Ferramenta: ",
+1 -1
View File
@@ -565,7 +565,7 @@
"ApprovalChooseAction": "Enter để chọn, hoặc nhấn y/a/d trực tiếp",
"ApprovalIntentLabel": "Ý định: ",
"ApprovalMoreLines": " … (+{count} dòng)",
"ApprovalAutoDeniedSession": "Đã tự động từ chối {tool}: bạn đã từ chối chính yêu cầu này trước đó trong phiên. Hãy khởi động lại CodeWhale để xem xét lại.",
"ApprovalAutoDeniedSession": "Đã tự động từ chối {tool}: một yêu cầu khớp đã bị từ chối trước đó trong lần chạy CodeWhale này. Hãy khởi động lại CodeWhale để xem xét lại.",
"ElevationTitleSandboxDenied": " ⚠ Sandbox Bị Từ Chối ",
"ElevationTitleRequired": " Yêu Cầu Nâng Cấp Sandbox ",
"ElevationFieldTool": " Công cụ: ",
+1 -1
View File
@@ -565,7 +565,7 @@
"ApprovalChooseAction": "Enter 执行选中项,或直接按 y/a/d",
"ApprovalIntentLabel": "意图:",
"ApprovalMoreLines": " … (还有 {count} 行)",
"ApprovalAutoDeniedSession": "已自动拒绝 {tool}:你之前在此会话中拒绝了相同请求。若要重新考虑,请重启 CodeWhale。",
"ApprovalAutoDeniedSession": "已自动拒绝 {tool}:在本次 CodeWhale 运行期间,已有匹配请求被拒绝。若要重新考虑,请重启 CodeWhale。",
"ElevationTitleSandboxDenied": " ⚠ 沙箱拒绝 ",
"ElevationTitleRequired": " 沙箱提权 ",
"ElevationFieldTool": " 工具:",
+1 -1
View File
@@ -45,7 +45,7 @@
"ApprovalChooseAction": "Enter 執行選中項,或直接按 y/a/d",
"ApprovalIntentLabel": "意圖:",
"ApprovalMoreLines": " … (還有 {count} 行)",
"ApprovalAutoDeniedSession": "已自動拒絕 {tool}:你先前在此工作階段中拒絕了相同請求。若要重新考慮,請重新啟動 CodeWhale。",
"ApprovalAutoDeniedSession": "已自動拒絕 {tool}:在本次 CodeWhale 執行期間,已有相符請求被拒絕。若要重新考慮,請重新啟動 CodeWhale。",
"ElevationTitleSandboxDenied": " ⚠, 沙箱拒絕 ",
"ElevationTitleRequired": " 沙箱提權 ",
"ElevationFieldTool": " 工具:",
+3 -3
View File
@@ -8,7 +8,8 @@ use serde_json::{Value, json};
use crate::utils::{flush_and_sync, open_append};
/// Append an audit event to `~/.codewhale/audit.log`.
/// Append an audit event to `$CODEWHALE_HOME/audit.log` (or the default
/// `~/.codewhale/audit.log` when no explicit CodeWhale home is configured).
///
/// This helper is best-effort by design: callers should not fail critical flows
/// if audit persistence fails.
@@ -40,6 +41,5 @@ fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
}
fn default_audit_path() -> anyhow::Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
Ok(home.join(".codewhale").join("audit.log"))
Ok(codewhale_config::codewhale_home()?.join("audit.log"))
}
+3 -4
View File
@@ -158,10 +158,9 @@ impl ActiveCell {
entry_idx
}
/// Push an entry with no tool id binding (used for non-tool grouping if
/// ever needed). Currently unused; kept for symmetry with Codex which
/// allows e.g. session-header cells to live in `active_cell`.
#[allow(dead_code)]
/// Push an entry with no tool id binding. Approval notices use this path
/// so they can remain in the transcript without shifting the virtual
/// indices of tools that are still running in `active_cell`.
pub fn push_untracked(&mut self, cell: HistoryCell) -> usize {
let entry_idx = self.entries.len();
self.entries.push(cell);
+21 -42
View File
@@ -4696,30 +4696,31 @@ impl App {
}
}
fn prune_expired_status_toasts(&mut self, now: Instant) {
let queued_before = self.status_toasts.len();
self.status_toasts.retain(|toast| !toast.is_expired(now));
let queued_removed = self.status_toasts.len() != queued_before;
let sticky_removed = self
.sticky_status
.as_ref()
.is_some_and(|toast| toast.is_expired(now));
if sticky_removed {
self.sticky_status = None;
}
if queued_removed || sticky_removed {
self.needs_redraw = true;
}
}
/// Up to `limit` currently-active toasts, most recent last (so a stacked
/// renderer iterating top-to-bottom shows the freshest message at the
/// bottom, like a chat log). Drains expired toasts off the front as a
/// side effect — same cleanup as `active_status_toast` so callers see a
/// consistent queue. Whalescale#439.
/// bottom, like a chat log). Prunes expired toasts throughout the queue as
/// a side effect — the same cleanup as `active_status_toast` so callers see
/// a consistent queue. Whalescale#439.
pub fn active_status_toasts(&mut self, limit: usize) -> Vec<StatusToast> {
self.sync_status_message_to_toasts();
let now = Instant::now();
while self
.status_toasts
.front()
.is_some_and(|toast| toast.is_expired(now))
{
self.status_toasts.pop_front();
self.needs_redraw = true;
}
if self
.sticky_status
.as_ref()
.is_some_and(|toast| toast.is_expired(now))
{
self.sticky_status = None;
self.needs_redraw = true;
}
self.prune_expired_status_toasts(now);
let mut out: Vec<StatusToast> = Vec::with_capacity(limit);
if let Some(sticky) = self.sticky_status.clone() {
@@ -4744,29 +4745,7 @@ impl App {
pub fn active_status_toast(&mut self) -> Option<StatusToast> {
self.sync_status_message_to_toasts();
let now = Instant::now();
let mut removed = false;
while self
.status_toasts
.front()
.is_some_and(|toast| toast.is_expired(now))
{
self.status_toasts.pop_front();
removed = true;
}
if self
.sticky_status
.as_ref()
.is_some_and(|toast| toast.is_expired(now))
{
self.sticky_status = None;
removed = true;
}
if removed {
self.needs_redraw = true;
}
self.prune_expired_status_toasts(now);
self.sticky_status
.clone()
+35
View File
@@ -3836,6 +3836,41 @@ fn status_classifier_does_not_paint_negated_success_green() {
assert_eq!(level, StatusToastLevel::Warning);
}
#[test]
fn status_toasts_expire_even_behind_a_persistent_entry() {
let mut app = App::new(test_options(false), &Config::default());
app.status_message = None;
app.last_status_message_seen = None;
app.status_toasts.clear();
app.sticky_status = None;
app.push_status_toast("persistent", StatusToastLevel::Info, None);
app.push_status_toast("expired-list", StatusToastLevel::Warning, Some(1));
app.status_toasts
.back_mut()
.expect("temporary toast")
.created_at = Instant::now() - std::time::Duration::from_millis(2);
let visible = app.active_status_toasts(3);
assert_eq!(
visible
.iter()
.map(|toast| toast.text.as_str())
.collect::<Vec<_>>(),
vec!["persistent"]
);
app.push_status_toast("expired-single", StatusToastLevel::Warning, Some(1));
app.status_toasts
.back_mut()
.expect("temporary toast")
.created_at = Instant::now() - std::time::Duration::from_millis(2);
let active = app.active_status_toast().expect("persistent toast remains");
assert_eq!(active.text, "persistent");
assert_eq!(app.status_toasts.len(), 1);
}
#[test]
fn onboarding_provider_copy_is_provider_neutral_in_en() {
use crate::localization::{Locale, MessageId, tr};
+12
View File
@@ -273,6 +273,18 @@ async fn auto_deny_session_approval(
let notice = app
.tr(MessageId::ApprovalAutoDeniedSession)
.replace("{tool}", tool_name);
let notice_cell = HistoryCell::System {
content: notice.clone(),
};
if let Some(active) = app.active_cell.as_mut() {
// Keep the notice in the live transcript without changing
// `history.len()`: running tools are addressed by virtual indices
// based on that length until the active cell is flushed.
active.push_untracked(notice_cell);
app.mark_history_updated();
} else {
app.add_message(notice_cell);
}
app.push_status_toast(notice, StatusToastLevel::Warning, Some(12_000));
}
+272 -6
View File
@@ -26,7 +26,7 @@ use crate::tui::ui_text::truncate_line_to_width;
use crate::tui::views::{HelpView, ModalView, ViewAction};
use crate::working_set::Workspace;
use crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::text::Span;
use ratatui::{Terminal, backend::TestBackend, text::Span};
use std::collections::{HashSet, VecDeque};
use std::ffi::OsString;
use std::path::PathBuf;
@@ -2119,8 +2119,28 @@ fn session_denied_cache_matches_only_approval_key() {
assert!(is_session_denied_for_key(&app, "file:edit_file:retry"));
}
fn render_underwater_test_app(app: &mut App, width: u16, height: u16) -> String {
app.onboarding_workspace_trust_gate = false;
app.onboarding = OnboardingState::None;
let config = Config::default();
let mut terminal =
Terminal::new(TestBackend::new(width, height)).expect("underwater test terminal");
terminal
.draw(|frame| render(frame, app, &config))
.expect("render underwater shell");
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>()
}
#[tokio::test]
async fn session_denied_cache_auto_deny_explains_the_cached_rejection() {
let home = SettingsHomeGuard::new();
let audit_path = home._tmp.path().join(".codewhale").join("audit.log");
let mut app = create_test_app();
let mut engine = mock_engine_handle();
let approval_key = "shell:exec_shell:git push secret-token";
@@ -2143,14 +2163,260 @@ async fn session_denied_cache_auto_deny_explains_the_cached_rejection() {
let toast = app.status_toasts.back().expect("auto-deny warning toast");
assert_eq!(toast.level, StatusToastLevel::Warning);
assert_eq!(toast.ttl_ms, Some(12_000));
assert!(toast.text.contains("rejected this exact request earlier"));
assert!(toast.text.contains("matching request was denied earlier"));
assert!(toast.text.contains("during this CodeWhale run"));
assert!(toast.text.contains("Restart CodeWhale"));
assert!(toast.text.contains("exec_shell"));
let history_notice = app
.history
.iter()
.rev()
.find_map(|cell| match cell {
HistoryCell::System { content } => Some(content.as_str()),
_ => None,
})
.expect("persistent auto-deny explanation");
assert_eq!(history_notice, toast.text);
for visible_text in [&toast.text, history_notice] {
assert!(
!visible_text.contains(approval_key)
&& !visible_text.contains("git push")
&& !visible_text.contains("secret-token"),
"the user-facing notice must not expose the approval key, command, or arguments"
);
}
let audit = std::fs::read_to_string(&audit_path).expect("isolated approval audit log");
assert!(audit.contains(approval_key));
let rendered = render_underwater_test_app(&mut app, 40, 12);
assert!(rendered.contains("Auto-denied"), "{rendered:?}");
assert!(
!toast.text.contains(approval_key)
&& !toast.text.contains("git push")
&& !toast.text.contains("secret-token"),
"the user-facing notice must not expose the approval key, command, or arguments"
rendered.contains("Restart") && rendered.contains("CodeWhale"),
"{rendered:?}"
);
}
#[tokio::test]
async fn session_denied_cache_notice_preserves_active_tool_index() {
let _home = SettingsHomeGuard::new();
let mut app = create_test_app();
let mut engine = mock_engine_handle();
let tool_id = "tool-retry";
let tool_name = "exec_shell";
handle_tool_call_started(
&mut app,
tool_id,
tool_name,
&serde_json::json!({"command": "git push secret-token"}),
);
let history_len = app.history.len();
let tool_index = app.tool_cells[tool_id];
assert_eq!(tool_index, history_len);
auto_deny_session_approval(
&mut app,
&engine.handle,
tool_id,
tool_name,
"shell:exec_shell:git push secret-token",
)
.await;
assert_eq!(
app.history.len(),
history_len,
"the notice must not shift virtual indices while a tool is active"
);
assert_eq!(app.tool_cells.get(tool_id), Some(&tool_index));
let active = app.active_cell.as_ref().expect("active tool and notice");
assert!(matches!(
active.entries().first(),
Some(HistoryCell::Tool(ToolCell::Exec(exec))) if exec.status == ToolStatus::Running
));
assert!(matches!(
active.entries().get(1),
Some(HistoryCell::System { content }) if content.contains("Auto-denied")
));
assert_eq!(
engine.recv_approval_event().await,
Some(crate::core::engine::MockApprovalEvent::Denied {
id: tool_id.to_string(),
})
);
let denied_result = Ok(crate::tools::spec::ToolResult::error(
"request denied by cached approval",
));
handle_tool_call_complete(&mut app, tool_id, tool_name, &denied_result);
let active = app.active_cell.as_ref().expect("completed tool and notice");
assert!(matches!(
active.entries().first(),
Some(HistoryCell::Tool(ToolCell::Exec(exec))) if exec.status == ToolStatus::Failed
));
assert!(matches!(
active.entries().get(1),
Some(HistoryCell::System { .. })
));
app.flush_active_cell();
assert!(matches!(
app.history.get(history_len),
Some(HistoryCell::Tool(ToolCell::Exec(exec))) if exec.status == ToolStatus::Failed
));
assert!(matches!(
app.history.get(history_len + 1),
Some(HistoryCell::System { content }) if content.contains("Auto-denied")
));
let detail = app
.tool_detail_record_for_cell(history_len)
.expect("tool detail remains bound to the completed tool cell");
assert_eq!(detail.tool_id, tool_id);
assert!(
detail
.output
.as_deref()
.is_some_and(|output| output.contains("cached approval"))
);
}
#[tokio::test]
async fn session_denied_cache_notice_preserves_parallel_tool_indices() {
let _home = SettingsHomeGuard::new();
let mut app = create_test_app();
let mut engine = mock_engine_handle();
handle_tool_call_started(
&mut app,
"tool-first",
"exec_shell",
&serde_json::json!({"command": "echo first"}),
);
handle_tool_call_started(
&mut app,
"tool-denied",
"exec_shell",
&serde_json::json!({"command": "git push secret-token"}),
);
let history_len = app.history.len();
let first_index = app.tool_cells["tool-first"];
let denied_index = app.tool_cells["tool-denied"];
assert_eq!(first_index, history_len);
assert_eq!(denied_index, history_len + 1);
auto_deny_session_approval(
&mut app,
&engine.handle,
"tool-denied",
"exec_shell",
"shell:exec_shell:git push secret-token",
)
.await;
assert_eq!(app.history.len(), history_len);
assert_eq!(app.tool_cells.get("tool-first"), Some(&first_index));
assert_eq!(app.tool_cells.get("tool-denied"), Some(&denied_index));
assert_eq!(
engine.recv_approval_event().await,
Some(crate::core::engine::MockApprovalEvent::Denied {
id: "tool-denied".to_string(),
})
);
let denied_result = Ok(crate::tools::spec::ToolResult::error(
"request denied by cached approval",
));
handle_tool_call_complete(&mut app, "tool-denied", "exec_shell", &denied_result);
let active = app.active_cell.as_ref().expect("parallel tools and notice");
assert!(matches!(
active.entries().first(),
Some(HistoryCell::Tool(ToolCell::Exec(exec)))
if exec.status == ToolStatus::Running && exec.command == "echo first"
));
assert!(matches!(
active.entries().get(1),
Some(HistoryCell::Tool(ToolCell::Exec(exec)))
if exec.status == ToolStatus::Failed
&& exec.command == "git push secret-token"
&& exec.output.as_deref().is_some_and(|output| output.contains("cached approval"))
));
assert!(matches!(
active.entries().get(2),
Some(HistoryCell::System { content }) if content.contains("Auto-denied")
));
handle_tool_call_complete(
&mut app,
"tool-first",
"exec_shell",
&ok_result("first output"),
);
app.flush_active_cell();
assert!(matches!(
app.history.get(history_len),
Some(HistoryCell::Tool(ToolCell::Exec(exec)))
if exec.status == ToolStatus::Success
&& exec.command == "echo first"
&& exec.output.as_deref() == Some("first output")
));
assert!(matches!(
app.history.get(history_len + 1),
Some(HistoryCell::Tool(ToolCell::Exec(exec)))
if exec.status == ToolStatus::Failed && exec.command == "git push secret-token"
));
assert!(matches!(
app.history.get(history_len + 2),
Some(HistoryCell::System { content }) if content.contains("Auto-denied")
));
}
#[tokio::test]
async fn session_denied_cache_notice_renders_host_scope_in_zh_hans() {
let _home = SettingsHomeGuard::new();
let mut app = create_test_app();
app.ui_locale = crate::localization::Locale::ZhHans;
let mut engine = mock_engine_handle();
auto_deny_session_approval(
&mut app,
&engine.handle,
"fetch-retry",
"fetch_url",
"net:example.com",
)
.await;
assert_eq!(
engine.recv_approval_event().await,
Some(crate::core::engine::MockApprovalEvent::Denied {
id: "fetch-retry".to_string(),
})
);
let notice = app
.history
.iter()
.rev()
.find_map(|cell| match cell {
HistoryCell::System { content } => Some(content.as_str()),
_ => None,
})
.expect("localized persistent auto-deny explanation");
assert!(notice.contains("本次 CodeWhale 运行期间"));
assert!(notice.contains("匹配请求"));
assert!(!notice.contains("example.com"));
let rendered = render_underwater_test_app(&mut app, 60, 16);
let rendered_compact = rendered
.chars()
.filter(|ch| !ch.is_whitespace())
.collect::<String>();
assert!(rendered_compact.contains("已自动拒绝"), "{rendered:?}");
assert!(rendered_compact.contains("匹配请求"), "{rendered:?}");
assert!(
rendered_compact.contains("重启") && rendered_compact.contains("CodeWhale"),
"{rendered:?}"
);
}