1use serde::{Deserialize, Serialize};
3
4#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum Approval {
7 #[default]
8 Expiring,
9 OncePerRun,
10}
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields, default)]
17pub struct PardonPolicy {
18 pub enabled: bool,
19 pub quiet_ms: u64,
21 pub budget: u32,
23 pub require_foreground: bool,
25 pub keyboard_safe_only: bool,
27}
28impl Default for PardonPolicy {
29 fn default() -> Self {
30 Self {
31 enabled: true,
32 quiet_ms: 4000,
33 budget: 2,
34 require_foreground: true,
35 keyboard_safe_only: true,
36 }
37 }
38}
39impl PardonPolicy {
40 pub fn valid(&self) -> bool {
41 (1000..=10000).contains(&self.quiet_ms) && (1..=5).contains(&self.budget)
42 }
43 fn is_default(&self) -> bool {
44 *self == Self::default()
45 }
46}
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub struct PardonInputs {
49 pub now_ms: u64,
50 pub revoker_hard: bool,
52 pub last_input_ms: Option<u64>,
54 pub hard_input_after_revoke: bool,
56 pub foreground_same: bool,
58 pub keyboard_action: bool,
60 pub budget_left: u32,
61}
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum PardonDecision {
64 Pardon,
66 Wait,
68 Never,
70}
71pub fn pardon_decision(inputs: PardonInputs, policy: &PardonPolicy) -> PardonDecision {
72 if !policy.enabled || inputs.revoker_hard || inputs.hard_input_after_revoke {
73 return PardonDecision::Never;
74 }
75 if policy.keyboard_safe_only && inputs.keyboard_action {
76 return PardonDecision::Never;
77 }
78 if inputs.budget_left == 0 {
79 return PardonDecision::Never;
80 }
81 if policy.require_foreground && !inputs.foreground_same {
82 return PardonDecision::Never;
83 }
84 match inputs.last_input_ms {
85 None => PardonDecision::Wait,
86 Some(last) if inputs.now_ms.saturating_sub(last) < policy.quiet_ms => PardonDecision::Wait,
87 Some(_) => PardonDecision::Pardon,
88 }
89}
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct Policy {
93 pub approval: Approval,
94 #[serde(default, skip_serializing_if = "PardonPolicy::is_default")]
95 pub pardon_input: PardonPolicy,
96}
97thread_local! { static POLICY: std::cell::Cell<Approval> = const { std::cell::Cell::new(Approval::Expiring) }; }
98pub fn configure(policy: Approval) {
99 POLICY.with(|p| p.set(policy));
100}
101pub fn configured() -> Approval {
102 POLICY.with(|p| p.get())
103}
104
105pub const INPUT_TAG: usize = 0x4143544c;
106pub const REQUEST_MESSAGE: u32 = 0x8000 + 46;
107pub const CHECK_MESSAGE: u32 = 0x8000 + 47;
108pub const RECOVER_MESSAGE: u32 = 0x8000 + 48;
109pub const FOREGROUND_MESSAGE: u32 = 0x8000 + 51;
110pub const PENDING: usize = 1;
111pub const ALLOWED: usize = 2;
112pub const DEFERRED: usize = 3;
113pub const YIELDED: usize = 4;
114pub const PANEL_UNAVAILABLE: usize = 5;
115pub const MONITOR_UNAVAILABLE: usize = 6;
116pub const USER_PAUSED: usize = 7;
117pub const CANCELLED: usize = 8;
118
119#[derive(Clone, Debug, Serialize, Deserialize)]
120pub struct Request {
121 pub approval: Approval,
122 pub generation: String,
123 pub call: String,
124 pub task: String,
125 pub target: String,
126 pub ts_ms: u64,
127 #[serde(default)]
129 pub action: String,
130}
131impl Request {
132 pub fn fresh(&self, now: u64) -> bool {
133 self.ts_ms <= now
134 && now - self.ts_ms < 2000
135 && !self.call.is_empty()
136 && !self.task.is_empty()
137 && !self.generation.is_empty()
138 }
139}
140
141#[derive(Default)]
142pub struct Consent {
143 grant: Option<(String, u64, u64, u64)>,
144 approval: Approval,
145 reason: Option<&'static str>,
146}
147pub const GRACE_MS: u64 = 750;
152impl Consent {
153 pub fn allow(&mut self, task: &str, input_seq: u64, now: u64) {
156 self.allow_with_policy(task, input_seq, now, Approval::Expiring);
157 }
158 pub fn allow_with_policy(&mut self, task: &str, input_seq: u64, now: u64, approval: Approval) {
159 self.approval = approval;
160 self.grant = Some((task.into(), input_seq, now, now));
161 self.reason = Some("allowed");
162 }
163 pub fn revoke(&mut self) {
164 self.revoke_with_reason("revoked");
165 }
166 pub fn revoke_with_reason(&mut self, reason: &'static str) {
167 if self.grant.take().is_some() || self.reason.is_none() {
168 self.reason = Some(reason);
169 }
170 }
171 pub fn reason(&self) -> &'static str {
172 self.reason.unwrap_or("no_grant")
173 }
174 pub fn check(&mut self, task: &str, input_seq: u64, now: u64) -> bool {
175 let Some((owner, seq, started, touched)) = self.grant.as_mut() else {
176 return false;
177 };
178 let expired = if now < *touched {
179 Some("clock_reversed")
180 } else if self.approval == Approval::Expiring && now - *started >= 600_000 {
181 Some("duration_expired")
182 } else if self.approval == Approval::Expiring && now - *touched >= 60_000 {
183 Some("idle_expired")
184 } else {
185 None
186 };
187 if let Some(reason) = expired {
188 self.revoke_with_reason(reason);
189 return false;
190 }
191 if *seq != input_seq {
192 if now - *started < GRACE_MS {
193 *seq = input_seq;
195 } else {
196 self.revoke_with_reason("external_input");
197 return false;
198 }
199 }
200 if owner != task {
201 self.reason = Some("different_task");
202 return false;
203 }
204 *touched = now;
205 true
206 }
207 pub fn check_policy(
208 &mut self,
209 task: &str,
210 input_seq: u64,
211 now: u64,
212 approval: Approval,
213 ) -> bool {
214 if self.grant.is_some() && self.approval != approval {
215 self.reason = Some("policy_changed");
216 return false;
217 }
218 self.check(task, input_seq, now)
219 }
220}
221
222pub fn external_input(injected: bool, tag: usize) -> bool {
224 !injected || tag != INPUT_TAG
225}
226
227pub fn error(reason: &str) -> crate::CtlError {
228 crate::CtlError::with_evidence(
229 crate::ErrorCode::NotActionable,
230 "desktop handoff required; user must choose Start/Continue in actl, then re-observe before retrying interrupted work",
231 serde_json::json!({"stage":"handoff", "reason":reason, "retry_input":false}),
232 )
233}
234
235pub fn foreground_error(expected: &str, actual: &str) -> crate::CtlError {
236 crate::CtlError::with_evidence(
237 crate::ErrorCode::NotActionable,
238 "physical foreground does not match the target; re-observe the window before retrying",
239 serde_json::json!({"stage":"foreground_verify", "reason":"foreground_mismatch", "expected":expected, "actual":actual}),
240 )
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 #[test]
247 fn injected_events_from_other_tools_still_yield() {
248 assert!(!external_input(true, INPUT_TAG));
249 assert!(external_input(false, INPUT_TAG));
250 assert!(external_input(false, 0));
251 assert!(external_input(true, 0));
252 }
253 #[test]
254 fn foreground_mismatch_never_recommends_elevation() {
255 let e = foreground_error("target", "");
256 assert_eq!(e.code, crate::ErrorCode::NotActionable);
257 assert!(!e.code.recovery_hint().contains("elevated"));
258 }
259 #[test]
260 fn consent_is_explicit_and_task_scoped() {
261 let mut c = Consent::default();
262 assert!(!c.check("a", 10, 100));
263 c.allow("a", 10, 100);
264 assert!(c.check("a", 10, 101));
265 assert!(!c.check("b", 10, 102));
266 assert!(c.check("a", 10, 103));
267 }
268 #[test]
269 fn same_field_input_revokes_even_without_focus_change() {
270 let mut c = Consent::default();
271 c.allow("a", 10, 10_000); assert!(!c.check("a", 11, 10_000 + GRACE_MS + 1));
273 assert!(!c.check("a", 10, 10_000 + GRACE_MS + 2));
274 c.allow("a", 12, 10_000 + GRACE_MS + 3);
275 assert!(c.check("a", 12, 10_000 + GRACE_MS + 4));
276 }
277
278 #[test]
279 fn grant_settle_grace_absorbs_then_goes_strict() {
280 let mut c = Consent::default();
281 c.allow("a", 10, 1_000);
282 assert!(c.check("a", 12, 1_050));
284 assert!(c.check("a", 15, 1_700));
285 assert!(!c.check("a", 16, 1_000 + GRACE_MS + 5));
287 assert!(!c.check("a", 16, 1_000 + GRACE_MS + 10));
289 }
290
291 #[test]
292 fn grace_does_not_change_task_scope_or_expiry() {
293 let mut c = Consent::default();
294 c.allow("a", 10, 1_000);
295 assert!(!c.check("b", 11, 1_100), "余韵不改任务归属");
296 assert!(c.check("a", 11, 1_100));
297 assert!(!c.check("a", 11, 1_100 + 60_000));
299 }
300 #[test]
301 fn expiry_restart_and_clock_reversal_fail_closed() {
302 let mut c = Consent::default();
303 c.allow("a", 0, 100);
304 assert!(!c.check("a", 0, 60_100));
305 c.allow("a", 0, 100);
306 assert!(!c.check("a", 0, 99));
307 c.allow("a", 0, 100);
308 for t in (101..600_100).step_by(1000) {
309 assert!(c.check("a", 0, t));
310 }
311 assert!(!c.check("a", 0, 600_100));
312 assert!(!Consent::default().check("a", 0, 101));
313 }
314
315 fn inputs(now: u64, last: Option<u64>) -> PardonInputs {
316 PardonInputs {
317 now_ms: now,
318 revoker_hard: false,
319 last_input_ms: last,
320 hard_input_after_revoke: false,
321 foreground_same: true,
322 keyboard_action: false,
323 budget_left: 2,
324 }
325 }
326 #[test]
327 fn pardon_waits_for_quiet_then_recovers_within_budget() {
328 let policy = PardonPolicy::default();
329 assert_eq!(
330 pardon_decision(inputs(10_000, Some(9_000)), &policy),
331 PardonDecision::Wait
332 );
333 assert_eq!(
334 pardon_decision(inputs(10_000, Some(10_000 - policy.quiet_ms)), &policy),
335 PardonDecision::Pardon
336 );
337 let drained = PardonInputs {
338 budget_left: 0,
339 ..inputs(10_000, Some(0))
340 };
341 assert_eq!(pardon_decision(drained, &policy), PardonDecision::Never);
342 }
343 #[test]
344 fn hard_input_keyboards_and_focus_change_never_pardon() {
345 let policy = PardonPolicy::default();
346 let hard_revoker = PardonInputs {
347 revoker_hard: true,
348 ..inputs(10_000, Some(0))
349 };
350 assert_eq!(
351 pardon_decision(hard_revoker, &policy),
352 PardonDecision::Never
353 );
354 let hard_after = PardonInputs {
355 hard_input_after_revoke: true,
356 ..inputs(10_000, Some(0))
357 };
358 assert_eq!(pardon_decision(hard_after, &policy), PardonDecision::Never);
359 let typing = PardonInputs {
360 keyboard_action: true,
361 ..inputs(10_000, Some(0))
362 };
363 assert_eq!(pardon_decision(typing, &policy), PardonDecision::Never);
364 let moved_focus = PardonInputs {
365 foreground_same: false,
366 ..inputs(10_000, Some(0))
367 };
368 assert_eq!(pardon_decision(moved_focus, &policy), PardonDecision::Never);
369 let relaxed = PardonPolicy {
370 require_foreground: false,
371 keyboard_safe_only: false,
372 ..policy
373 };
374 assert_eq!(
375 pardon_decision(
376 PardonInputs {
377 keyboard_action: true,
378 foreground_same: false,
379 ..inputs(10_000, Some(0))
380 },
381 &relaxed
382 ),
383 PardonDecision::Pardon
384 );
385 }
386 #[test]
387 fn disabled_policy_and_unknown_ranges_fail_closed() {
388 assert!(
389 !PardonPolicy {
390 quiet_ms: 999,
391 ..PardonPolicy::default()
392 }
393 .valid()
394 );
395 assert!(
396 !PardonPolicy {
397 budget: 0,
398 ..PardonPolicy::default()
399 }
400 .valid()
401 );
402 let off = PardonPolicy {
403 enabled: false,
404 ..PardonPolicy::default()
405 };
406 assert_eq!(
407 pardon_decision(inputs(10_000, Some(0)), &off),
408 PardonDecision::Never
409 );
410 assert_eq!(
411 serde_json::from_str::<Policy>("{\"approval\":\"once_per_run\"}").unwrap(),
412 Policy {
413 approval: Approval::OncePerRun,
414 pardon_input: PardonPolicy::default()
415 }
416 );
417 }
418}