Skip to main content

agent_engine/tools/sudo_askpass/
mod.rs

1//! sudo passwords for the bash tool, through sudo's own askpass hook (Linux).
2//!
3//! The bash tool runs `sudo` as `sudo -A` with `SUDO_ASKPASS` pointing at a
4//! private link to this very binary (`synaps-sudo-askpass`). sudo starts that
5//! helper only when it really needs a password, passes its prompt as argv[1]
6//! and reads the password from the helper's stdout. The helper asks the bash
7//! tool call that set it up, over a Unix socket in a 0700 per-call directory,
8//! and the tool call raises the secret prompt.
9//!
10//! Before anything is shown to the user, both ends check who is asking:
11//!
12//! * the helper's parent must be the system sudo: started by this user and
13//!   running set-uid root (`/proc/<pid>/status`: real uid ours, effective and
14//!   saved uid 0, name `sudo`). `/proc/<sudo>/exe` is NOT readable to us:
15//!   sudo is a non-dumpable set-uid process, so the kernel refuses (EACCES,
16//!   checked against sudo 1.9.17). The credentials in `status` are kept by
17//!   the kernel and cannot be forged by a process of the same user; if `exe`
18//!   is readable (running as root), it must also be a root-owned set-uid file.
19//! * the broker re-checks everything from the socket's peer credentials, so
20//!   a process that skips the helper and talks to the socket gains nothing:
21//!   peer uid ours, peer runs this binary, peer's parent is the system sudo,
22//!   and that sudo descends from the shell this tool call spawned, so a
23//!   request cannot cross between tool calls or sessions;
24//! * a random token, rotated after every use, must match.
25//!
26//! The prompt names the command sudo is about to run (its argv, sanitised
27//! and bounded), so the user can judge the request. The password goes only
28//! to the helper, which writes it to sudo's pipe. It never reaches the
29//! command's stdin (which is empty) or the model, and it is redacted from
30//! the tool output if the command echoes it.
31//!
32//! Limits: a command inside the tool call that runs `sudo -A` itself still
33//! gets a prompt (that is how sudo works), which is why the prompt shows the
34//! command. Code running as the same user can ptrace the helper or point its
35//! own sudo at its own askpass program; that is the user's account, not a
36//! boundary this can draw. Two sudo password requests racing in one call
37//! share one token, so the second is refused.
38
39// The broker side only exists with the bash tool (`command-exec`).
40#![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
53/// File name of the helper link; `main` dispatches on it (argv[0]).
54pub(crate) const HELPER_NAME: &str = "synaps-sudo-askpass";
55/// The broker's socket, next to the helper link.
56pub(crate) const SOCKET_NAME: &str = "sock";
57/// The current one-shot token, next to the helper link.
58pub(crate) const TOKEN_NAME: &str = "token";
59/// Upper bound on a password (and on any reply) crossing the socket.
60pub(crate) const MAX_REPLY: usize = 4096;
61/// Upper bound on the sudo prompt the helper forwards.
62pub(crate) const MAX_PROMPT: usize = 512;
63
64/// Why a password request was refused. Never carries a secret.
65#[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
80/// Who may ask. Production uses [`SystemPolicy`]; tests inject fakes for
81/// the parts that need a real set-uid sudo.
82pub(crate) trait CallerPolicy: Send + Sync {
83    /// `pid` is the system sudo.
84    fn check_sudo(&self, pid: u32) -> Result<(), Refusal>;
85    /// `pid` runs the askpass helper (this binary).
86    fn check_helper(&self, pid: u32) -> Result<(), Refusal>;
87    /// `pid` is `root` or one of its descendants.
88    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
99/// The real checks, against `/proc`.
100pub(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        // SAFETY: getuid has no preconditions and cannot fail.
108        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
127/// Is the process described by `status` (and its `exe`, normally
128/// unreadable) the system sudo, started by the user `my_uid`?
129pub(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        // The expected case: a set-uid process is not dumpable, so its exe
156        // link is closed to its own (unprivileged) user.
157        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
162/// One line of untrusted text made safe to show: control and invisible
163/// formatting characters (bidi overrides, zero-width) are escaped, and the
164/// result is cut to `max` characters with an ellipsis.
165pub(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
195/// The command sudo will run, from its argv, quoted like a shell would
196/// need it, sanitised and bounded.
197pub(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
215/// Title of the secret prompt (a `Secret` prompt: masked input).
216pub(crate) const PROMPT_TITLE: &str = "sudo password required";
217
218/// The body of the secret prompt: what sudo will run, then sudo's prompt.
219pub(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    /// What sudo 1.9.17 looks like from its own (unprivileged) user.
246    #[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    /// When `exe` is readable it must be a root-owned set-uid file: a file
277    /// of ours (not root-owned, not set-uid) is refused.
278    #[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    /// The real policy against real processes: neither this test process
289    /// nor a program renamed `sudo` (same name, our uid) is the system sudo.
290    #[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        // A link, not a copy: exec'ing a freshly written file races other
300        // tests' forks (ETXTBSY). The process is still named `sudo`.
301        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    /// A command line cannot draw its own lines, escape sequences or
355    /// reordered text into the prompt.
356    #[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}