Fix violation prompt evidence gating

This commit is contained in:
2026-05-26 10:05:42 -04:00
parent fd166d1ebb
commit 3ff868c4e8
+138 -16
View File
@@ -589,14 +589,22 @@ fn should_prompt_for_unknown_window(
window_snapshot: &window::WindowSnapshot,
) -> bool {
let allowed_context = &policy.allowed_context;
if allowed_context.window_title_substrings.is_empty()
&& allowed_context.window_classes.is_empty()
{
let title_policy = !allowed_context.window_title_substrings.is_empty();
let class_policy = !allowed_context.window_classes.is_empty();
if !title_policy && !class_policy {
return false;
}
let title_matches = window_title_allowed(allowed_context, &window_snapshot.title);
let class_matches = window_snapshot
let title_usable = usable_title_evidence(window_snapshot);
let class_usable = usable_class_evidence(window_snapshot);
if (title_policy && !title_usable) || (class_policy && !class_usable) {
return false;
}
let title_matches =
title_policy && window_title_allowed(allowed_context, &window_snapshot.title);
let class_matches = class_policy
&& window_snapshot
.class
.as_ref()
.is_some_and(|class| window_class_allowed(allowed_context, class));
@@ -604,6 +612,34 @@ fn should_prompt_for_unknown_window(
!(title_matches || class_matches)
}
fn usable_title_evidence(window_snapshot: &window::WindowSnapshot) -> bool {
let title = window_snapshot.title.trim();
!title.is_empty()
&& title != "none"
&& !evidence_unavailable(&window_snapshot.health)
&& !degraded_evidence_mentions(&window_snapshot.health, "title unavailable")
}
fn usable_class_evidence(window_snapshot: &window::WindowSnapshot) -> bool {
window_snapshot
.class
.as_deref()
.is_some_and(|class| !class.trim().is_empty())
&& !evidence_unavailable(&window_snapshot.health)
&& !degraded_evidence_mentions(&window_snapshot.health, "class unavailable")
}
fn evidence_unavailable(health: &EvidenceHealth) -> bool {
matches!(health, EvidenceHealth::Unavailable(_))
}
fn degraded_evidence_mentions(health: &EvidenceHealth, needle: &str) -> bool {
match health {
EvidenceHealth::Degraded(reason) => reason.to_lowercase().contains(needle),
EvidenceHealth::Available | EvidenceHealth::Unavailable(_) => false,
}
}
fn session_stats_as_vec(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<SessionRating> {
let mut stats: Vec<_> = session_stats
.iter()
@@ -662,12 +698,16 @@ fn handle_events(app: &mut App) -> Result<()> {
return Ok(());
}
if key.code == KeyCode::Esc {
handle_key_press(app, key.code)
}
fn handle_key_press(app: &mut App, key_code: KeyCode) -> Result<()> {
if key_code == KeyCode::Esc && app.state != State::ViolationPrompt {
app.state = State::ShouldQuit;
}
match app.state {
State::InputIntention => match key.code {
State::InputIntention => match key_code {
KeyCode::Enter => app.state = State::InputSuccessCondition,
KeyCode::Tab => app.state = State::InputSuccessCondition,
KeyCode::Backspace => {
@@ -678,7 +718,7 @@ fn handle_events(app: &mut App) -> Result<()> {
}
_ => {}
},
State::InputSuccessCondition => match key.code {
State::InputSuccessCondition => match key_code {
KeyCode::Enter => app.state = State::InputDuration,
KeyCode::Tab => app.state = State::InputDuration,
KeyCode::Backspace => {
@@ -689,7 +729,7 @@ fn handle_events(app: &mut App) -> Result<()> {
}
_ => {}
},
State::InputDuration => match key.code {
State::InputDuration => match key_code {
KeyCode::Enter => app.to_in_progress()?,
KeyCode::Tab => app.state = State::InputIntention,
KeyCode::Backspace => {
@@ -700,7 +740,7 @@ fn handle_events(app: &mut App) -> Result<()> {
}
_ => {}
},
State::InputTransitionReason => match key.code {
State::InputTransitionReason => match key_code {
KeyCode::Enter => app.state = State::InputTransitionReturn,
KeyCode::Tab => app.state = State::InputTransitionReturn,
KeyCode::Backspace => {
@@ -711,7 +751,7 @@ fn handle_events(app: &mut App) -> Result<()> {
}
_ => {}
},
State::InputTransitionReturn => match key.code {
State::InputTransitionReturn => match key_code {
KeyCode::Enter => app.state = State::InputTransitionDuration,
KeyCode::Tab => app.state = State::InputTransitionDuration,
KeyCode::Backspace => {
@@ -722,7 +762,7 @@ fn handle_events(app: &mut App) -> Result<()> {
}
_ => {}
},
State::InputTransitionDuration => match key.code {
State::InputTransitionDuration => match key_code {
KeyCode::Enter => app.start_transition_if_valid()?,
KeyCode::Tab => app.state = State::InputTransitionReason,
KeyCode::Backspace => {
@@ -733,7 +773,7 @@ fn handle_events(app: &mut App) -> Result<()> {
}
_ => {}
},
State::InProgress => match key.code {
State::InProgress => match key_code {
KeyCode::Char('q') => {
app.to_end()?;
}
@@ -749,11 +789,11 @@ fn handle_events(app: &mut App) -> Result<()> {
_ => {}
},
State::Paused => {
if key.code == KeyCode::Char('p') {
if key_code == KeyCode::Char('p') {
app.resume()?;
}
}
State::ViolationPrompt => match key.code {
State::ViolationPrompt => match key_code {
KeyCode::Enter => app.dismiss_violation_if_valid()?,
KeyCode::Backspace => {
let _ = app.violation_dismissal_reason.pop();
@@ -765,7 +805,7 @@ fn handle_events(app: &mut App) -> Result<()> {
},
State::ShouldQuit => (),
State::End => {
let code = match key.code {
let code = match key_code {
KeyCode::Char('1') => 1,
KeyCode::Char('2') => 2,
KeyCode::Char('3') => 3,
@@ -1197,6 +1237,73 @@ mod tests {
assert!(should_prompt_for_unknown_window(&policy, &snapshot));
}
#[test]
fn unknown_window_prompt_ignores_unavailable_title_evidence() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext {
window_title_substrings: vec!["Editor".to_string()],
..AllowedContext::default()
},
);
let snapshot = window::WindowSnapshot::unavailable("no active window");
assert!(!should_prompt_for_unknown_window(&policy, &snapshot));
}
#[test]
fn unknown_window_prompt_ignores_class_policy_without_class_evidence() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext {
window_classes: vec!["Code".to_string()],
..AllowedContext::default()
},
);
let snapshot = window::WindowSnapshot {
title: "Browser - unrelated".to_string(),
class: None,
health: antidrift::domain::EvidenceHealth::Degraded(
"window class unavailable on current Windows adapter".to_string(),
),
};
assert!(!should_prompt_for_unknown_window(&policy, &snapshot));
}
#[test]
fn unknown_window_prompt_requires_usable_evidence_for_mixed_policy() {
let policy = PolicySnapshot::for_runtime(
"commitment-test".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext {
window_title_substrings: vec!["Editor".to_string()],
window_classes: vec!["Code".to_string()],
..AllowedContext::default()
},
);
let missing_class = window::WindowSnapshot {
title: "Browser - unrelated".to_string(),
class: None,
health: antidrift::domain::EvidenceHealth::Degraded(
"window class unavailable on current Windows adapter".to_string(),
),
};
let proved_unknown = window::WindowSnapshot {
title: "Browser - unrelated".to_string(),
class: Some("firefox".to_string()),
health: antidrift::domain::EvidenceHealth::Available,
};
assert!(!should_prompt_for_unknown_window(&policy, &missing_class));
assert!(should_prompt_for_unknown_window(&policy, &proved_unknown));
}
#[test]
fn acknowledged_unknown_window_is_suppressed_until_window_changes() {
let path = unique_event_log_path("violation-ack-suppression");
@@ -1222,6 +1329,21 @@ mod tests {
let _ = fs::remove_file(path);
}
#[test]
fn violation_prompt_ignores_esc() {
let path = unique_event_log_path("violation-prompt-esc");
let mut app = App::new_with_event_log_path(&path).unwrap();
app.state = State::ViolationPrompt;
app.violation_message = "Unknown context detected: Browser - unrelated".to_string();
handle_key_press(&mut app, KeyCode::Esc).unwrap();
assert_eq!(app.state, State::ViolationPrompt);
assert!(app.violation_dismissal_reason.is_empty());
let _ = fs::remove_file(path);
}
#[test]
fn violation_prompt_requires_reason_then_logs_and_returns_to_progress() {
let path = unique_event_log_path("violation-prompt-dismissal");