Fix violation prompt evidence gating
This commit is contained in:
+138
-16
@@ -589,14 +589,22 @@ fn should_prompt_for_unknown_window(
|
|||||||
window_snapshot: &window::WindowSnapshot,
|
window_snapshot: &window::WindowSnapshot,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let allowed_context = &policy.allowed_context;
|
let allowed_context = &policy.allowed_context;
|
||||||
if allowed_context.window_title_substrings.is_empty()
|
let title_policy = !allowed_context.window_title_substrings.is_empty();
|
||||||
&& allowed_context.window_classes.is_empty()
|
let class_policy = !allowed_context.window_classes.is_empty();
|
||||||
{
|
if !title_policy && !class_policy {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let title_matches = window_title_allowed(allowed_context, &window_snapshot.title);
|
let title_usable = usable_title_evidence(window_snapshot);
|
||||||
let class_matches = 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
|
.class
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|class| window_class_allowed(allowed_context, class));
|
.is_some_and(|class| window_class_allowed(allowed_context, class));
|
||||||
@@ -604,6 +612,34 @@ fn should_prompt_for_unknown_window(
|
|||||||
!(title_matches || class_matches)
|
!(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> {
|
fn session_stats_as_vec(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<SessionRating> {
|
||||||
let mut stats: Vec<_> = session_stats
|
let mut stats: Vec<_> = session_stats
|
||||||
.iter()
|
.iter()
|
||||||
@@ -662,12 +698,16 @@ fn handle_events(app: &mut App) -> Result<()> {
|
|||||||
return Ok(());
|
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;
|
app.state = State::ShouldQuit;
|
||||||
}
|
}
|
||||||
|
|
||||||
match app.state {
|
match app.state {
|
||||||
State::InputIntention => match key.code {
|
State::InputIntention => match key_code {
|
||||||
KeyCode::Enter => app.state = State::InputSuccessCondition,
|
KeyCode::Enter => app.state = State::InputSuccessCondition,
|
||||||
KeyCode::Tab => app.state = State::InputSuccessCondition,
|
KeyCode::Tab => app.state = State::InputSuccessCondition,
|
||||||
KeyCode::Backspace => {
|
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::Enter => app.state = State::InputDuration,
|
||||||
KeyCode::Tab => app.state = State::InputDuration,
|
KeyCode::Tab => app.state = State::InputDuration,
|
||||||
KeyCode::Backspace => {
|
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::Enter => app.to_in_progress()?,
|
||||||
KeyCode::Tab => app.state = State::InputIntention,
|
KeyCode::Tab => app.state = State::InputIntention,
|
||||||
KeyCode::Backspace => {
|
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::Enter => app.state = State::InputTransitionReturn,
|
||||||
KeyCode::Tab => app.state = State::InputTransitionReturn,
|
KeyCode::Tab => app.state = State::InputTransitionReturn,
|
||||||
KeyCode::Backspace => {
|
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::Enter => app.state = State::InputTransitionDuration,
|
||||||
KeyCode::Tab => app.state = State::InputTransitionDuration,
|
KeyCode::Tab => app.state = State::InputTransitionDuration,
|
||||||
KeyCode::Backspace => {
|
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::Enter => app.start_transition_if_valid()?,
|
||||||
KeyCode::Tab => app.state = State::InputTransitionReason,
|
KeyCode::Tab => app.state = State::InputTransitionReason,
|
||||||
KeyCode::Backspace => {
|
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') => {
|
KeyCode::Char('q') => {
|
||||||
app.to_end()?;
|
app.to_end()?;
|
||||||
}
|
}
|
||||||
@@ -749,11 +789,11 @@ fn handle_events(app: &mut App) -> Result<()> {
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
State::Paused => {
|
State::Paused => {
|
||||||
if key.code == KeyCode::Char('p') {
|
if key_code == KeyCode::Char('p') {
|
||||||
app.resume()?;
|
app.resume()?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
State::ViolationPrompt => match key.code {
|
State::ViolationPrompt => match key_code {
|
||||||
KeyCode::Enter => app.dismiss_violation_if_valid()?,
|
KeyCode::Enter => app.dismiss_violation_if_valid()?,
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
let _ = app.violation_dismissal_reason.pop();
|
let _ = app.violation_dismissal_reason.pop();
|
||||||
@@ -765,7 +805,7 @@ fn handle_events(app: &mut App) -> Result<()> {
|
|||||||
},
|
},
|
||||||
State::ShouldQuit => (),
|
State::ShouldQuit => (),
|
||||||
State::End => {
|
State::End => {
|
||||||
let code = match key.code {
|
let code = match key_code {
|
||||||
KeyCode::Char('1') => 1,
|
KeyCode::Char('1') => 1,
|
||||||
KeyCode::Char('2') => 2,
|
KeyCode::Char('2') => 2,
|
||||||
KeyCode::Char('3') => 3,
|
KeyCode::Char('3') => 3,
|
||||||
@@ -1197,6 +1237,73 @@ mod tests {
|
|||||||
assert!(should_prompt_for_unknown_window(&policy, &snapshot));
|
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]
|
#[test]
|
||||||
fn acknowledged_unknown_window_is_suppressed_until_window_changes() {
|
fn acknowledged_unknown_window_is_suppressed_until_window_changes() {
|
||||||
let path = unique_event_log_path("violation-ack-suppression");
|
let path = unique_event_log_path("violation-ack-suppression");
|
||||||
@@ -1222,6 +1329,21 @@ mod tests {
|
|||||||
let _ = fs::remove_file(path);
|
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]
|
#[test]
|
||||||
fn violation_prompt_requires_reason_then_logs_and_returns_to_progress() {
|
fn violation_prompt_requires_reason_then_logs_and_returns_to_progress() {
|
||||||
let path = unique_event_log_path("violation-prompt-dismissal");
|
let path = unique_event_log_path("violation-prompt-dismissal");
|
||||||
|
|||||||
Reference in New Issue
Block a user