agent_engine/tools/sudo_askpass/
mod.rs1#![cfg_attr(not(feature = "command-exec"), allow(dead_code))]
41
42#[cfg(feature = "command-exec")]
43pub(crate) mod broker;
44mod helper;
45pub(crate) mod procfs;
46#[cfg(all(feature = "command-exec", any(test, feature = "testing")))]
47pub mod testing;
48#[cfg(all(test, feature = "command-exec"))]
49mod tests_e2e;
50
51pub use helper::run_helper_if_invoked;
52
53pub(crate) const HELPER_NAME: &str = "synaps-sudo-askpass";
55pub(crate) const SOCKET_NAME: &str = "sock";
57pub(crate) const TOKEN_NAME: &str = "token";
59pub(crate) const MAX_REPLY: usize = 4096;
61pub(crate) const MAX_PROMPT: usize = 512;
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub(crate) struct Refusal(pub(crate) String);
67
68impl Refusal {
69 pub(crate) fn new(why: impl Into<String>) -> Self {
70 Self(why.into())
71 }
72}
73
74impl std::fmt::Display for Refusal {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.write_str(&self.0)
77 }
78}
79
80pub(crate) trait CallerPolicy: Send + Sync {
83 fn check_sudo(&self, pid: u32) -> Result<(), Refusal>;
85 fn check_helper(&self, pid: u32) -> Result<(), Refusal>;
87 fn check_ancestry(&self, pid: u32, root: u32) -> Result<(), Refusal> {
89 if procfs::descends_from(pid, root) {
90 Ok(())
91 } else {
92 Err(Refusal::new(
93 "sudo is not running inside this bash tool call",
94 ))
95 }
96 }
97}
98
99pub(crate) struct SystemPolicy;
101
102impl CallerPolicy for SystemPolicy {
103 fn check_sudo(&self, pid: u32) -> Result<(), Refusal> {
104 let status = procfs::status(pid)
105 .map_err(|e| Refusal::new(format!("cannot inspect the parent process: {e}")))?;
106 let exe = procfs::exe_metadata(pid);
107 judge_sudo(&status, unsafe { libc::getuid() }, exe)
109 }
110
111 fn check_helper(&self, pid: u32) -> Result<(), Refusal> {
112 let ours = procfs::exe_metadata(std::process::id())
113 .map_err(|e| Refusal::new(format!("cannot inspect this binary: {e}")))?;
114 let theirs = procfs::exe_metadata(pid)
115 .map_err(|e| Refusal::new(format!("cannot inspect the requester: {e}")))?;
116 use std::os::unix::fs::MetadataExt;
117 if (ours.dev(), ours.ino()) == (theirs.dev(), theirs.ino()) {
118 Ok(())
119 } else {
120 Err(Refusal::new(
121 "the requester is not the synaps askpass helper",
122 ))
123 }
124 }
125}
126
127pub(crate) fn judge_sudo(
130 status: &procfs::ProcStatus,
131 my_uid: u32,
132 exe: std::io::Result<std::fs::Metadata>,
133) -> Result<(), Refusal> {
134 if status.name != "sudo" {
135 return Err(Refusal::new("the parent process is not sudo"));
136 }
137 let [real, effective, saved, _fs] = status.uids;
138 if real != my_uid {
139 return Err(Refusal::new("sudo was started by another user"));
140 }
141 if effective != 0 || saved != 0 {
142 return Err(Refusal::new("the parent sudo is not running set-uid root"));
143 }
144 match exe {
145 Ok(meta) => {
146 use std::os::unix::fs::{MetadataExt, PermissionsExt};
147 let mode = meta.permissions().mode();
148 if !meta.is_file() || meta.uid() != 0 || mode & 0o4000 == 0 || mode & 0o022 != 0 {
149 return Err(Refusal::new(
150 "the parent sudo is not a root-owned set-uid binary",
151 ));
152 }
153 Ok(())
154 }
155 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => Ok(()),
158 Err(e) => Err(Refusal::new(format!("cannot inspect the parent sudo: {e}"))),
159 }
160}
161
162pub(crate) fn sanitize_line(text: &str, max: usize) -> String {
166 let mut out = String::new();
167 let mut count = 0;
168 for ch in text.chars() {
169 let piece = if is_unsafe_char(ch) {
170 ch.escape_unicode().to_string()
171 } else {
172 ch.to_string()
173 };
174 let len = piece.chars().count();
175 if count + len > max {
176 out.push('…');
177 return out;
178 }
179 out.push_str(&piece);
180 count += len;
181 }
182 out
183}
184
185fn is_unsafe_char(ch: char) -> bool {
186 ch.is_control()
187 || matches!(ch,
188 '\u{200B}'..='\u{200F}'
189 | '\u{2028}'..='\u{202E}'
190 | '\u{2060}'..='\u{2069}'
191 | '\u{FEFF}'
192 | '\u{FFF9}'..='\u{FFFB}')
193}
194
195pub(crate) fn display_command(argv: &[String], max: usize) -> String {
198 let mut words = vec!["sudo".to_string()];
199 words.extend(argv.iter().skip(1).map(|arg| shell_quote(arg)));
200 sanitize_line(&words.join(" "), max)
201}
202
203fn shell_quote(arg: &str) -> String {
204 let plain = !arg.is_empty()
205 && arg
206 .chars()
207 .all(|c| c.is_ascii_alphanumeric() || "_@%+=:,./-".contains(c));
208 if plain {
209 arg.to_string()
210 } else {
211 format!("'{}'", arg.replace('\'', r"'\''"))
212 }
213}
214
215pub(crate) const PROMPT_TITLE: &str = "sudo password required";
217
218pub(crate) fn prompt_text(sudo_argv: &[String], sudo_prompt: &str) -> String {
220 let command = display_command(sudo_argv, 240);
221 let asked = sanitize_line(sudo_prompt.trim(), 120);
222 if asked.is_empty() {
223 format!("sudo is asking for your password to run:\n{command}")
224 } else {
225 format!("sudo is asking for your password to run:\n{command}\n{asked}")
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn status(name: &str, uids: [u32; 4]) -> procfs::ProcStatus {
234 procfs::ProcStatus {
235 name: name.into(),
236 ppid: 1,
237 uids,
238 }
239 }
240
241 fn denied() -> std::io::Result<std::fs::Metadata> {
242 Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
243 }
244
245 #[test]
247 fn a_setuid_root_sudo_started_by_us_is_accepted() {
248 assert_eq!(
249 judge_sudo(&status("sudo", [1000, 0, 0, 0]), 1000, denied()),
250 Ok(())
251 );
252 }
253
254 #[test]
255 fn anything_else_is_refused() {
256 let cases = [
257 (status("bash", [1000, 0, 0, 0]), "not sudo"),
258 (
259 status("sudo", [1000, 1000, 1000, 1000]),
260 "not running set-uid root",
261 ),
262 (
263 status("sudo", [1000, 0, 1000, 0]),
264 "not running set-uid root",
265 ),
266 (status("sudo", [1001, 0, 0, 0]), "another user"),
267 ];
268 for (st, why) in cases {
269 let err = judge_sudo(&st, 1000, denied()).unwrap_err();
270 assert!(err.0.contains(why), "{st:?}: {err}");
271 }
272 let gone = Err(std::io::Error::from(std::io::ErrorKind::NotFound));
273 assert!(judge_sudo(&status("sudo", [1000, 0, 0, 0]), 1000, gone).is_err());
274 }
275
276 #[test]
279 fn a_readable_exe_must_be_a_root_owned_setuid_file() {
280 let dir = tempfile::tempdir().unwrap();
281 let file = dir.path().join("sudo");
282 std::fs::write(&file, b"x").unwrap();
283 let meta = std::fs::metadata(&file);
284 let err = judge_sudo(&status("sudo", [1000, 0, 0, 0]), 1000, meta).unwrap_err();
285 assert!(err.0.contains("root-owned set-uid"), "{err}");
286 }
287
288 #[test]
291 fn the_system_policy_refuses_impostors() {
292 let me = std::process::id();
293 assert!(SystemPolicy
294 .check_sudo(me)
295 .unwrap_err()
296 .0
297 .contains("not sudo"));
298
299 let dir = tempfile::tempdir().unwrap();
302 let fake = dir.path().join("sudo");
303 std::os::unix::fs::symlink("/bin/sleep", &fake).unwrap();
304 let mut child = std::process::Command::new(&fake).arg("30").spawn().unwrap();
305 let pid = child.id();
306 procfs::tests::wait_for_name(pid, "sudo");
307 let verdict = SystemPolicy.check_sudo(pid);
308 let _ = child.kill();
309 let _ = child.wait();
310 assert!(verdict.unwrap_err().0.contains("not running set-uid root"));
311 }
312
313 #[test]
314 fn the_system_policy_knows_its_own_binary() {
315 assert_eq!(SystemPolicy.check_helper(std::process::id()), Ok(()));
316 let mut child = std::process::Command::new("sleep")
317 .arg("30")
318 .spawn()
319 .unwrap();
320 procfs::tests::wait_for_name(child.id(), "sleep");
321 let verdict = SystemPolicy.check_helper(child.id());
322 let _ = child.kill();
323 let _ = child.wait();
324 assert!(verdict
325 .unwrap_err()
326 .0
327 .contains("not the synaps askpass helper"));
328 }
329
330 #[test]
331 fn prompt_text_names_the_command_and_is_bounded() {
332 let argv: Vec<String> = ["sudo", "-A", "apt", "install", "it's here"]
333 .iter()
334 .map(|s| s.to_string())
335 .collect();
336 let text = prompt_text(&argv, "[sudo] password for me: ");
337 assert_eq!(
338 text,
339 "sudo is asking for your password to run:\n\
340 sudo -A apt install 'it'\\''s here'\n[sudo] password for me:"
341 );
342
343 let long: Vec<String> = std::iter::once("sudo".to_string())
344 .chain(std::iter::repeat_n("x".repeat(50), 40))
345 .collect();
346 let text = prompt_text(&long, &"p".repeat(1000));
347 let lines: Vec<&str> = text.lines().collect();
348 assert_eq!(lines.len(), 3);
349 assert_eq!(lines[1].chars().count(), 241, "240 + ellipsis");
350 assert!(lines[1].ends_with('…'));
351 assert_eq!(lines[2].chars().count(), 121);
352 }
353
354 #[test]
357 fn prompt_text_escapes_control_and_invisible_characters() {
358 let argv: Vec<String> = ["sudo", "rm\n-rf /\x1b[2J", "a\u{202e}b", "z\u{200b}w"]
359 .iter()
360 .map(|s| s.to_string())
361 .collect();
362 let text = prompt_text(&argv, "pw\r\n:");
363 let lines: Vec<&str> = text.lines().collect();
364 assert_eq!(lines.len(), 3, "{text:?}");
365 assert!(lines[1].contains(r"\u{a}"), "{text:?}");
366 assert!(lines[1].contains(r"\u{1b}"), "{text:?}");
367 assert!(lines[1].contains(r"\u{202e}"), "{text:?}");
368 assert!(lines[1].contains(r"\u{200b}"), "{text:?}");
369 assert!(!text.contains('\u{1b}') && !text.contains('\u{202e}') && !text.contains('\r'));
370 assert_eq!(lines[2], r"pw\u{d}\u{a}:");
371 }
372}