use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Approval {
#[default]
Expiring,
OncePerRun,
Standing,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct PardonPolicy {
pub enabled: bool,
pub quiet_ms: u64,
pub budget: u32,
pub require_foreground: bool,
pub keyboard_safe_only: bool,
}
impl Default for PardonPolicy {
fn default() -> Self {
Self {
enabled: true,
quiet_ms: 4000,
budget: 2,
require_foreground: true,
keyboard_safe_only: true,
}
}
}
impl PardonPolicy {
pub fn valid(&self) -> bool {
(1000..=10000).contains(&self.quiet_ms) && (1..=5).contains(&self.budget)
}
fn is_default(&self) -> bool {
*self == Self::default()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PardonInputs {
pub now_ms: u64,
pub revoker_hard: bool,
pub last_input_ms: Option<u64>,
pub hard_input_after_revoke: bool,
pub foreground_same: bool,
pub keyboard_action: bool,
pub budget_left: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PardonDecision {
Pardon,
Wait,
Never,
}
pub fn pardon_decision(inputs: PardonInputs, policy: &PardonPolicy) -> PardonDecision {
if !policy.enabled || inputs.revoker_hard || inputs.hard_input_after_revoke {
return PardonDecision::Never;
}
if policy.keyboard_safe_only && inputs.keyboard_action {
return PardonDecision::Never;
}
if inputs.budget_left == 0 {
return PardonDecision::Never;
}
if policy.require_foreground && !inputs.foreground_same {
return PardonDecision::Never;
}
match inputs.last_input_ms {
None => PardonDecision::Wait,
Some(last) if inputs.now_ms.saturating_sub(last) < policy.quiet_ms => PardonDecision::Wait,
Some(_) => PardonDecision::Pardon,
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Policy {
pub approval: Approval,
#[serde(default, skip_serializing_if = "PardonPolicy::is_default")]
pub pardon_input: PardonPolicy,
}
thread_local! { static POLICY: std::cell::Cell<Approval> = const { std::cell::Cell::new(Approval::Expiring) }; }
pub fn configure(policy: Approval) {
POLICY.with(|p| p.set(policy));
}
pub fn configured() -> Approval {
POLICY.with(|p| p.get())
}
pub const INPUT_TAG: usize = 0x4143544c;
pub const REQUEST_MESSAGE: u32 = 0x8000 + 46;
pub const CHECK_MESSAGE: u32 = 0x8000 + 47;
pub const RECOVER_MESSAGE: u32 = 0x8000 + 48;
pub const FOREGROUND_MESSAGE: u32 = 0x8000 + 51;
pub const PENDING: usize = 1;
pub const ALLOWED: usize = 2;
pub const DEFERRED: usize = 3;
pub const YIELDED: usize = 4;
pub const PANEL_UNAVAILABLE: usize = 5;
pub const MONITOR_UNAVAILABLE: usize = 6;
pub const USER_PAUSED: usize = 7;
pub const CANCELLED: usize = 8;
pub const YIELD_BUSY: usize = 9;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Request {
pub approval: Approval,
pub generation: String,
pub call: String,
pub task: String,
pub target: String,
pub ts_ms: u64,
#[serde(default)]
pub action: String,
}
impl Request {
pub fn fresh(&self, now: u64) -> bool {
self.ts_ms <= now
&& now - self.ts_ms < 2000
&& !self.call.is_empty()
&& !self.task.is_empty()
&& !self.generation.is_empty()
}
}
#[derive(Default)]
pub struct Consent {
grant: Option<(String, u64, u64, u64)>,
approval: Approval,
reason: Option<&'static str>,
}
pub const GRACE_MS: u64 = 750;
impl Consent {
pub fn allow(&mut self, task: &str, input_seq: u64, now: u64) {
self.allow_with_policy(task, input_seq, now, Approval::Expiring);
}
pub fn allow_with_policy(&mut self, task: &str, input_seq: u64, now: u64, approval: Approval) {
self.approval = approval;
self.grant = Some((task.into(), input_seq, now, now));
self.reason = Some("allowed");
}
pub fn revoke(&mut self) {
self.revoke_with_reason("revoked");
}
pub fn revoke_with_reason(&mut self, reason: &'static str) {
if self.grant.take().is_some() || self.reason.is_none() {
self.reason = Some(reason);
}
}
pub fn reason(&self) -> &'static str {
self.reason.unwrap_or("no_grant")
}
pub fn check(&mut self, task: &str, input_seq: u64, now: u64) -> bool {
let Some((owner, seq, started, touched)) = self.grant.as_mut() else {
return false;
};
let expired = if now < *touched {
Some("clock_reversed")
} else if self.approval == Approval::Expiring && now - *started >= 600_000 {
Some("duration_expired")
} else if self.approval == Approval::Expiring && now - *touched >= 60_000 {
Some("idle_expired")
} else {
None
};
if let Some(reason) = expired {
self.revoke_with_reason(reason);
return false;
}
if *seq != input_seq {
if now - *started < GRACE_MS {
*seq = input_seq;
} else if self.approval == Approval::Standing {
self.reason = Some("input_active");
*seq = input_seq;
return false;
} else {
self.revoke_with_reason("external_input");
return false;
}
}
if owner != task {
self.reason = Some("different_task");
return false;
}
*touched = now;
true
}
pub fn check_policy(
&mut self,
task: &str,
input_seq: u64,
now: u64,
approval: Approval,
) -> bool {
if self.grant.is_some() && self.approval != approval {
self.reason = Some("policy_changed");
return false;
}
self.check(task, input_seq, now)
}
}
pub fn external_input(injected: bool, tag: usize) -> bool {
!injected || tag != INPUT_TAG
}
pub fn error(reason: &str) -> crate::CtlError {
crate::CtlError::with_evidence(
crate::ErrorCode::NotActionable,
"desktop handoff required; user must choose Start/Continue in actl, then re-observe before retrying interrupted work",
serde_json::json!({"stage":"handoff", "reason":reason, "retry_input":false}),
)
}
pub fn foreground_error(expected: &str, actual: &str) -> crate::CtlError {
crate::CtlError::with_evidence(
crate::ErrorCode::NotActionable,
"physical foreground does not match the target; re-observe the window before retrying",
serde_json::json!({"stage":"foreground_verify", "reason":"foreground_mismatch", "expected":expected, "actual":actual}),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn injected_events_from_other_tools_still_yield() {
assert!(!external_input(true, INPUT_TAG));
assert!(external_input(false, INPUT_TAG));
assert!(external_input(false, 0));
assert!(external_input(true, 0));
}
#[test]
fn foreground_mismatch_never_recommends_elevation() {
let e = foreground_error("target", "");
assert_eq!(e.code, crate::ErrorCode::NotActionable);
assert!(!e.code.recovery_hint().contains("elevated"));
}
#[test]
fn consent_is_explicit_and_task_scoped() {
let mut c = Consent::default();
assert!(!c.check("a", 10, 100));
c.allow("a", 10, 100);
assert!(c.check("a", 10, 101));
assert!(!c.check("b", 10, 102));
assert!(c.check("a", 10, 103));
}
#[test]
fn same_field_input_revokes_even_without_focus_change() {
let mut c = Consent::default();
c.allow("a", 10, 10_000); assert!(!c.check("a", 11, 10_000 + GRACE_MS + 1));
assert!(!c.check("a", 10, 10_000 + GRACE_MS + 2));
c.allow("a", 12, 10_000 + GRACE_MS + 3);
assert!(c.check("a", 12, 10_000 + GRACE_MS + 4));
}
#[test]
fn standing_trust_never_expires_and_yields_without_revoking() {
let mut c = Consent::default();
c.allow_with_policy("a", 10, 100, Approval::Standing);
assert!(c.check("a", 10, 100 + 700_000));
assert!(!c.check("a", 11, 100 + 700_001));
assert_eq!(c.reason(), "input_active");
assert!(c.check("a", 11, 100 + 700_002));
assert!(!c.check("b", 11, 100 + 700_003));
c.revoke_with_reason("stopped");
assert!(!c.check("a", 11, 100 + 700_004));
}
#[test]
fn grant_settle_grace_absorbs_then_goes_strict() {
let mut c = Consent::default();
c.allow("a", 10, 1_000);
assert!(c.check("a", 12, 1_050));
assert!(c.check("a", 15, 1_700));
assert!(!c.check("a", 16, 1_000 + GRACE_MS + 5));
assert!(!c.check("a", 16, 1_000 + GRACE_MS + 10));
}
#[test]
fn grace_does_not_change_task_scope_or_expiry() {
let mut c = Consent::default();
c.allow("a", 10, 1_000);
assert!(!c.check("b", 11, 1_100), "余韵不改任务归属");
assert!(c.check("a", 11, 1_100));
assert!(!c.check("a", 11, 1_100 + 60_000));
}
#[test]
fn expiry_restart_and_clock_reversal_fail_closed() {
let mut c = Consent::default();
c.allow("a", 0, 100);
assert!(!c.check("a", 0, 60_100));
c.allow("a", 0, 100);
assert!(!c.check("a", 0, 99));
c.allow("a", 0, 100);
for t in (101..600_100).step_by(1000) {
assert!(c.check("a", 0, t));
}
assert!(!c.check("a", 0, 600_100));
assert!(!Consent::default().check("a", 0, 101));
}
fn inputs(now: u64, last: Option<u64>) -> PardonInputs {
PardonInputs {
now_ms: now,
revoker_hard: false,
last_input_ms: last,
hard_input_after_revoke: false,
foreground_same: true,
keyboard_action: false,
budget_left: 2,
}
}
#[test]
fn pardon_waits_for_quiet_then_recovers_within_budget() {
let policy = PardonPolicy::default();
assert_eq!(
pardon_decision(inputs(10_000, Some(9_000)), &policy),
PardonDecision::Wait
);
assert_eq!(
pardon_decision(inputs(10_000, Some(10_000 - policy.quiet_ms)), &policy),
PardonDecision::Pardon
);
let drained = PardonInputs {
budget_left: 0,
..inputs(10_000, Some(0))
};
assert_eq!(pardon_decision(drained, &policy), PardonDecision::Never);
}
#[test]
fn hard_input_keyboards_and_focus_change_never_pardon() {
let policy = PardonPolicy::default();
let hard_revoker = PardonInputs {
revoker_hard: true,
..inputs(10_000, Some(0))
};
assert_eq!(
pardon_decision(hard_revoker, &policy),
PardonDecision::Never
);
let hard_after = PardonInputs {
hard_input_after_revoke: true,
..inputs(10_000, Some(0))
};
assert_eq!(pardon_decision(hard_after, &policy), PardonDecision::Never);
let typing = PardonInputs {
keyboard_action: true,
..inputs(10_000, Some(0))
};
assert_eq!(pardon_decision(typing, &policy), PardonDecision::Never);
let moved_focus = PardonInputs {
foreground_same: false,
..inputs(10_000, Some(0))
};
assert_eq!(pardon_decision(moved_focus, &policy), PardonDecision::Never);
let relaxed = PardonPolicy {
require_foreground: false,
keyboard_safe_only: false,
..policy
};
assert_eq!(
pardon_decision(
PardonInputs {
keyboard_action: true,
foreground_same: false,
..inputs(10_000, Some(0))
},
&relaxed
),
PardonDecision::Pardon
);
}
#[test]
fn disabled_policy_and_unknown_ranges_fail_closed() {
assert!(
!PardonPolicy {
quiet_ms: 999,
..PardonPolicy::default()
}
.valid()
);
assert!(
!PardonPolicy {
budget: 0,
..PardonPolicy::default()
}
.valid()
);
let off = PardonPolicy {
enabled: false,
..PardonPolicy::default()
};
assert_eq!(
pardon_decision(inputs(10_000, Some(0)), &off),
PardonDecision::Never
);
assert_eq!(
serde_json::from_str::<Policy>("{\"approval\":\"once_per_run\"}").unwrap(),
Policy {
approval: Approval::OncePerRun,
pardon_input: PardonPolicy::default()
}
);
}
}