Repository: memorysafety/sudo-rs
Pull request #88 (2023-03-14 10:35:12)

Correctly accumulate TAG:'s
Closing #22.


Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 8f608fa8..ff7e4489 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -7,6 +7,9 @@ on:
   pull_request:
     branches:
       - main
+  merge_group:
+    branches:
+      - main
 
 jobs:
   compliance-tests:
diff --git a/Cargo.lock b/Cargo.lock
index b1ce822d..25542a4b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -449,6 +449,26 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3"
 
+[[package]]
+name = "signal-hook"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "732768f1176d21d09e076c23a93123d40bba92d50c4058da34d45c8de8e682b9"
+dependencies = [
+ "cc",
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "strsim"
 version = "0.10.0"
@@ -461,6 +481,8 @@ version = "0.1.0-alpha.1"
 dependencies = [
  "sudo-cli",
  "sudo-common",
+ "sudo-env",
+ "sudo-exec",
  "sudo-pam",
  "sudo-system",
  "sudoers",
@@ -498,6 +520,25 @@ dependencies = [
 name = "sudo-defaults"
 version = "0.1.0-alpha.1"
 
+[[package]]
+name = "sudo-env"
+version = "0.1.0-alpha.1"
+dependencies = [
+ "sudo-cli",
+ "sudo-common",
+ "sudo-system",
+ "sudoers",
+]
+
+[[package]]
+name = "sudo-exec"
+version = "0.1.0-alpha.1"
+dependencies = [
+ "signal-hook",
+ "sudo-common",
+ "sudo-system",
+]
+
 [[package]]
 name = "sudo-pam"
 version = "0.1.0-alpha.1"
diff --git a/Cargo.toml b/Cargo.toml
index dce9822b..164cb590 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,11 +5,13 @@ members = [
   "lib/sudo-cli",
   "lib/sudo-common",
   "lib/sudo-cutils",
+  "lib/sudo-env",
   "lib/sudo-system",
   "lib/sudoers",
   "lib/sudo-defaults",
   "lib/sudo-pam-sys",
   "lib/sudo-pam",
+  "lib/sudo-exec",
 ]
 default-members = ["sudo"]
 
@@ -30,6 +32,7 @@ rpassword = "7.2.0"
 glob = "0.3.1"
 sha2 = "0.10.6"
 digest = "0.10.6"
+signal-hook = "0.3.15"
 
 # Test only dependencies
 pretty_assertions = "1.3.0"
@@ -38,10 +41,12 @@ pretty_assertions = "1.3.0"
 bindgen = "0.63.0"
 
 sudo-cli = { version = "0.1.0-alpha.1", path = "./lib/sudo-cli" }
+sudo-env = { version = "0.1.0-alpha.1", path = "./lib/sudo-env" }
 sudo-pam = { version = "0.1.0-alpha.1", path = "./lib/sudo-pam" }
 sudo-system = { version = "0.1.0-alpha.1", path = "./lib/sudo-system" }
 sudo-pam-sys = { version = "0.1.0-alpha.1", path = "./lib/sudo-pam-sys" }
 sudo-cutils = { version = "0.1.0-alpha.1", path = "./lib/sudo-cutils" }
 sudo-common = { version = "0.1.0-alpha.1", path = "./lib/sudo-common" }
 sudo-defaults = { version = "0.1.0-alpha.1", path = "./lib/sudo-defaults" }
+sudo-exec = { version = "0.1.0-alpha.1", path = "./lib/sudo-exec" }
 sudoers = { version = "0.1.0-alpha.1", path = "./lib/sudoers" }
diff --git a/README.md b/README.md
index d92ee98c..b41e7ab3 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,49 @@
 # sudo-rs
-A memory safe implementation of sudo and su.
+
+A safety oriented and memory safe implementation in Rust of sudo and su.
+
+## !!WARNING!!
+
+**Sudo-rs is currently under active development and is not suited for any
+production environment. Using sudo-rs is only recommended for development and
+testing purposes, but you should expect any system that has sudo-rs installed to
+break easily and to not be secure.**
+
+## Quick start
+
+Sudo-rs currently only supports Linux-based operating systems, although other
+unix-based operating systems may work, they are currently not actively
+supported or maintained.
+
+Sudo-rs is written in Rust. We currently only support the latest stable compiler
+toolchain. To build sudo-rs run
+
+```
+cargo build --release
+```
+
+This produces a binary `target/release/sudo`. However this binary must have the
+setuid flag set and must be owned by the root user in order to provide any
+useful functionality. Because we are in such an early stage we also require
+an environment variable `SUDO_RS_IS_UNSTABLE` to be set, and it must have the
+value `I accept that my system may break unexpectedly`. If you are unsure how
+to set this value then this software is not suited for you at this time.
+
+Sudo-rs needs the sudoers configuration file, but it currently reads it at the
+`/etc/sudoers.test` location, so make sure that your sudoers configuration is at
+that location. For an explanation of the sudoers syntax you can look at the
+[original sudo man page](https://www.sudo.ws/docs/man/sudoers.man/). While most
+syntax should be supported as is, most functionality will currently not be
+implemented. Sudo-rs currently may not always warn about this, so your sudoers
+file may have a different meaning compared to the original sudo implementation.
+
+Sudo-rs always uses PAM for authentication at this time, your system must be
+setup for PAM. Sudo-rs will use the sudo service configuration.
+
+## Contributions
+
+While we are still working on getting the basic infrastructure and architecture
+of sudo-rs up and running, accepting arbitrary contributions will be difficult.
+If you have any small changes or suggestions please do leave those, but if you
+want to work on larger parts of sudo-rs please ask first, or we may risk doing
+work twice or not being able to include your work.
diff --git a/lib/sudo-common/Cargo.toml b/lib/sudo-common/Cargo.toml
index da22a675..094056fb 100644
--- a/lib/sudo-common/Cargo.toml
+++ b/lib/sudo-common/Cargo.toml
@@ -6,7 +6,6 @@ license.workspace = true
 repository.workspace = true
 homepage.workspace = true
 publish.workspace = true
-build = "build.rs"
 
 [dependencies]
 sudo-cli.workspace = true
diff --git a/lib/sudo-common/src/context.rs b/lib/sudo-common/src/context.rs
index 9ff2c882..123d8325 100644
--- a/lib/sudo-common/src/context.rs
+++ b/lib/sudo-common/src/context.rs
@@ -1,9 +1,15 @@
-use std::{collections::HashSet, path::PathBuf, str::FromStr};
+use std::{
+    collections::{HashMap, HashSet},
+    path::PathBuf,
+    str::FromStr,
+};
 use sudo_cli::SudoOptions;
 use sudo_system::{hostname, Group, User};
 use sudoers::Settings;
 
-use crate::{env::Environment, error::Error};
+use crate::error::Error;
+
+pub type Environment = HashMap<String, String>;
 
 #[derive(Debug)]
 pub struct CommandAndArguments<'a> {
@@ -29,6 +35,7 @@ impl<'a> TryFrom<&'a [String]> for CommandAndArguments<'a> {
     }
 }
 
+#[derive(PartialEq, Debug)]
 enum NameOrId<'a, T: FromStr> {
     Name(&'a str),
     Id(T),
@@ -63,31 +70,12 @@ pub struct Context<'a> {
     pub current_user: User,
 }
 
-/// A ContextWithEnv is specific to the operation and obtaining it indicates approval
-#[derive(Debug)]
-pub struct ContextWithEnv<'a> {
-    pub context: Context<'a>,
-    // command-specific
-    pub settings: &'a Settings,
-    // computed
-    pub target_environment: Environment,
-}
-
 pub trait Configuration {
-    fn env_delete(&self) -> &HashSet<String>;
     fn env_keep(&self) -> &HashSet<String>;
     fn env_check(&self) -> &HashSet<String>;
-    fn always_set_home(&self) -> bool;
-    fn use_pty(&self) -> bool;
 }
 
 impl Configuration for Settings {
-    fn env_delete(&self) -> &HashSet<String> {
-        self.list
-            .get("env_delete")
-            .expect("env_delete missing from settings")
-    }
-
     fn env_keep(&self) -> &HashSet<String> {
         self.list
             .get("env_keep")
@@ -99,14 +87,6 @@ impl Configuration for Settings {
             .get("env_check")
             .expect("env_check missing from settings")
     }
-
-    fn always_set_home(&self) -> bool {
-        self.flags.contains("always_set_home")
-    }
-
-    fn use_pty(&self) -> bool {
-        self.flags.contains("use_pty")
-    }
 }
 
 fn resolve_current_user() -> Result<User, Error> {
@@ -147,8 +127,8 @@ impl<'a> Context<'a> {
     pub fn build_from_options(sudo_options: &'a SudoOptions) -> Result<Context<'a>, Error> {
         let command = CommandAndArguments::try_from(sudo_options.external_args.as_slice())?;
         let hostname = hostname();
-        let current_user = resolve_current_user()?;
-        let target_user = resolve_target_user(&sudo_options.user)?;
+        let current_user = resolve_current_user()?.with_groups();
+        let target_user = resolve_target_user(&sudo_options.user)?.with_groups();
         let target_group = resolve_target_group(&sudo_options.group, &target_user)?;
 
         Ok(Context {
@@ -164,51 +144,79 @@ impl<'a> Context<'a> {
             chdir: sudo_options.directory.clone(),
         })
     }
-
-    pub fn with_filtered_env(
-        self,
-        current_env: Environment,
-        settings: &'a Settings,
-    ) -> ContextWithEnv<'a> {
-        let env = crate::env::get_target_environment(current_env, &self, settings);
-        ContextWithEnv {
-            context: self,
-            settings,
-            target_environment: env,
-        }
-    }
 }
 
 #[cfg(test)]
 mod tests {
     use std::collections::HashMap;
-
-    use super::Context;
     use sudo_cli::SudoOptions;
-    use sudoers::Settings;
+    use sudo_system::User;
+
+    use super::{resolve_target_group, resolve_target_user, Context, NameOrId};
 
     #[test]
-    fn test_build_context() {
-        let options = SudoOptions::try_parse_from(["sudo", "echo", "hello"]).unwrap();
+    fn test_name_or_id() {
+        assert_eq!(NameOrId::<u32>::parse(""), None);
+        assert_eq!(NameOrId::<u32>::parse("mies"), Some(NameOrId::Name("mies")));
+        assert_eq!(NameOrId::<u32>::parse("1337"), Some(NameOrId::Name("1337")));
+        assert_eq!(NameOrId::<u32>::parse("#1337"), Some(NameOrId::Id(1337)));
+        assert_eq!(NameOrId::<u32>::parse("#-1"), None);
+    }
 
-        let mut current_env = HashMap::new();
-        current_env.insert("FOO".to_string(), "BAR".to_string());
+    #[test]
+    fn test_resolve_target_user() {
+        assert_eq!(
+            resolve_target_user(&Some("mies".to_string())).is_err(),
+            true
+        );
+        assert_eq!(resolve_target_user(&Some("root".to_string())).is_ok(), true);
+        assert_eq!(resolve_target_user(&Some("#1".to_string())).is_ok(), true);
+        assert_eq!(resolve_target_user(&Some("#-1".to_string())).is_err(), true);
+        assert_eq!(
+            resolve_target_user(&Some("#1337".to_string())).is_err(),
+            true
+        );
+    }
 
-        let settings = Settings::default();
-        let context = Context::build_from_options(&options)
-            .unwrap()
-            .with_filtered_env(current_env, &settings);
+    #[test]
+    fn test_resolve_target_group() {
+        let current_user = User {
+            uid: 1000,
+            gid: 1000,
+            name: "test".to_string(),
+            gecos: String::new(),
+            home: "/home/test".to_string(),
+            shell: "/bin/sh".to_string(),
+            passwd: String::new(),
+            groups: None,
+        };
 
         assert_eq!(
-            context.context.command.command.to_str().unwrap(),
-            "/usr/bin/echo"
+            resolve_target_group(&Some("root".to_string()), &current_user).is_ok(),
+            true
+        );
+        assert_eq!(
+            resolve_target_group(&Some("#1".to_string()), &current_user).is_ok(),
+            true
         );
-        assert_eq!(context.context.command.arguments, ["hello"]);
-        assert_eq!(context.context.hostname, sudo_system::hostname());
-        assert_eq!(context.context.target_user.uid, 0);
         assert_eq!(
-            context.target_environment["SUDO_USER"],
-            context.context.current_user.name
+            resolve_target_group(&Some("#-1".to_string()), &current_user).is_err(),
+            true
         );
     }
+
+    #[test]
+    fn test_build_context() {
+        let options = SudoOptions::try_parse_from(["sudo", "echo", "hello"]).unwrap();
+
+        let context = Context::build_from_options(&options).unwrap();
+
+        let mut target_environment = HashMap::new();
+        target_environment.insert("SUDO_USER".to_string(), context.current_user.name.clone());
+
+        assert_eq!(context.command.command.to_str().unwrap(), "/usr/bin/echo");
+        assert_eq!(context.command.arguments, ["hello"]);
+        assert_eq!(context.hostname, sudo_system::hostname());
+        assert_eq!(context.target_user.uid, 0);
+    }
 }
diff --git a/lib/sudo-common/src/exec.rs b/lib/sudo-common/src/exec.rs
deleted file mode 100644
index 6e5aeee5..00000000
--- a/lib/sudo-common/src/exec.rs
+++ /dev/null
@@ -1,16 +0,0 @@
-use std::{
-    os::unix::process::CommandExt,
-    process::{Command, ExitStatus},
-};
-
-use crate::{context::ContextWithEnv, error::Error};
-
-pub fn exec(setup: ContextWithEnv) -> Result<ExitStatus, Error> {
-    Command::new(setup.context.command.command)
-        .args(setup.context.command.arguments)
-        .uid(setup.context.target_user.uid)
-        .gid(setup.context.target_user.gid)
-        .envs(setup.target_environment)
-        .status()
-        .map_err(|_| Error::Exec)
-}
diff --git a/lib/sudo-common/src/lib.rs b/lib/sudo-common/src/lib.rs
index d84c1aa0..b385a90d 100644
--- a/lib/sudo-common/src/lib.rs
+++ b/lib/sudo-common/src/lib.rs
@@ -1,8 +1,4 @@
 #![forbid(unsafe_code)]
 
 pub mod context;
-pub mod env;
 pub mod error;
-pub mod exec;
-pub mod pam;
-pub mod wildcard_match;
diff --git a/lib/sudo-env/Cargo.toml b/lib/sudo-env/Cargo.toml
new file mode 100644
index 00000000..82122cb8
--- /dev/null
+++ b/lib/sudo-env/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "sudo-env"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+homepage.workspace = true
+publish.workspace = true
+build = "build.rs"
+
+[dependencies]
+sudo-system.workspace = true
+sudo-common.workspace = true
+
+[dev-dependencies]
+sudo-cli.workspace = true
+sudoers.workspace = true
\ No newline at end of file
diff --git a/lib/sudo-common/build.rs b/lib/sudo-env/build.rs
similarity index 85%
rename from lib/sudo-common/build.rs
rename to lib/sudo-env/build.rs
index 9823b511..eb5e43e2 100644
--- a/lib/sudo-common/build.rs
+++ b/lib/sudo-env/build.rs
@@ -17,6 +17,9 @@ fn main() {
     let path_maildir: &str =
         get_first_path(&["/var/mail", "/var/spool/mail", "/usr/spool/mail"]).unwrap_or("/var/mail");
 
+    // TODO: use _PATH_STDPATH from paths.h
+    println!("cargo:rustc-env=PATH_DEFAULT=/usr/bin:/bin:/usr/sbin:/sbin");
+
     println!("cargo:rustc-env=PATH_MAILDIR={path_maildir}");
     println!("cargo:rustc-env=PATH_ZONEINFO={path_zoneinfo}");
     println!("cargo:rerun-if-changed=build.rs");
diff --git a/lib/sudo-common/src/env.rs b/lib/sudo-env/src/environment.rs
similarity index 54%
rename from lib/sudo-common/src/env.rs
rename to lib/sudo-env/src/environment.rs
index 118cf7d5..53679390 100644
--- a/lib/sudo-common/src/env.rs
+++ b/lib/sudo-env/src/environment.rs
@@ -1,14 +1,13 @@
-use crate::{
-    context::{CommandAndArguments, Configuration, Context},
-    wildcard_match::wildcard_match,
-};
-use std::collections::{HashMap, HashSet};
+use std::collections::HashSet;
+
+use sudo_common::context::{CommandAndArguments, Configuration, Context, Environment};
 use sudo_system::PATH_MAX;
 
-pub type Environment = HashMap<String, String>;
+use crate::wildcard_match::wildcard_match;
 
 const PATH_MAILDIR: &str = env!("PATH_MAILDIR");
 const PATH_ZONEINFO: &str = env!("PATH_ZONEINFO");
+const PATH_DEFAULT: &str = env!("PATH_DEFAULT");
 
 /// Formats the command and arguments passed for the SUDO_COMMAND
 /// environment variable. Limit the length of arguments to 4096 bytes to prevent
@@ -32,41 +31,38 @@ fn format_command(command_and_arguments: &CommandAndArguments) -> String {
 }
 
 /// Construct sudo-specific environment variables
-fn get_extra_env(
-    context: &Context,
-    settings: &impl Configuration,
-) -> impl Iterator<Item = (String, String)> {
-    let mut extra = vec![
-        ("SUDO_COMMAND", format_command(&context.command)),
-        ("SUDO_UID", context.current_user.uid.to_string()),
-        ("SUDO_GID", context.current_user.gid.to_string()),
-        ("SUDO_USER", context.current_user.name.clone()),
-        (
-            "MAIL",
-            format!("{PATH_MAILDIR}/{}", context.target_user.name),
-        ),
-    ];
-
-    // TODO: preserve existing when sudo -s
-    if !context.shell {
-        extra.push(("SHELL", context.target_user.shell.clone()));
-    }
-
-    // TODO: Set to the login name of the target user when the -i option is specified,
+fn add_extra_env(context: &Context, environment: &mut Environment) {
+    // current user
+    environment.insert("SUDO_COMMAND".to_string(), format_command(&context.command));
+    environment.insert("SUDO_UID".to_string(), context.current_user.uid.to_string());
+    environment.insert("SUDO_GID".to_string(), context.current_user.gid.to_string());
+    environment.insert("SUDO_USER".to_string(), context.current_user.name.clone());
+    // target user
+    environment.insert(
+        "MAIL".to_string(),
+        format!("{PATH_MAILDIR}/{}", context.target_user.name),
+    );
+    // The current SHELL variable should determine the shell to run when -s is passed, if none set use passwd entry
+    environment.insert("SHELL".to_string(), context.target_user.shell.clone());
+    // HOME' Set to the home directory of the target user if -i or -H are specified, env_reset or always_set_home are
+    // set in sudoers, or when the -s option is specified and set_home is set in sudoers.
+    // Since we always want to do env_reset -> always set HOME
+    environment.insert("HOME".to_string(), context.target_user.home.clone());
+    // Set to the login name of the target user when the -i option is specified,
     // when the set_logname option is enabled in sudoers, or when the env_reset option
     // is enabled in sudoers (unless LOGNAME is present in the env_keep list).
-    if context.login {
-        extra.push(("LOGNAME", context.target_user.name.clone()));
-        extra.push(("USER", context.target_user.name.clone()));
+    // Since we always want to do env_reset -> always set these except when present in env
+    if !environment.contains_key("LOGNAME") && !environment.contains_key("USER") {
+        environment.insert("LOGNAME".to_string(), context.target_user.name.clone());
+        environment.insert("USER".to_string(), context.target_user.name.clone());
     }
-
-    if settings.always_set_home() || context.set_home {
-        extra.push(("HOME", context.target_user.home.clone()));
+    // If the PATH and TERM variables are not preserved from the user's environment, they will be set to default value
+    if !environment.contains_key("PATH") {
+        environment.insert("PATH".to_string(), PATH_DEFAULT.to_string());
+    }
+    if !environment.contains_key("TERM") {
+        environment.insert("TERM".to_string(), "unknown".to_string());
     }
-
-    extra
-        .into_iter()
-        .map(|(name, value)| (name.to_string(), value))
 }
 
 /// Check a string only contains printable (non-space) characters
@@ -135,7 +131,7 @@ fn should_keep(key: &str, value: &str, cfg: &impl Configuration) -> bool {
 /// Additional variables, such as DISPLAY, PATH and TERM, are preserved from the invoking user's
 /// environment if permitted by the env_check, or env_keep options
 ///
-/// TODO: If the PATH and TERM variables are not preserved from the user's environment, they will be set to default value
+/// If the PATH and TERM variables are not preserved from the user's environment, they will be set to default value
 ///
 /// Environment variables with a value beginning with ‘()’ are removed
 pub fn get_target_environment(
@@ -143,16 +139,53 @@ pub fn get_target_environment(
     context: &Context,
     settings: &impl Configuration,
 ) -> Environment {
-    current_env
+    let mut environment = current_env
         .into_iter()
         .filter(|(key, value)| should_keep(key, value, settings))
-        .chain(get_extra_env(context, settings))
-        .collect()
+        .collect();
+
+    add_extra_env(context, &mut environment);
+
+    environment
 }
 
 #[cfg(test)]
 mod tests {
-    use crate::env::{is_safe_tz, PATH_ZONEINFO};
+    use std::collections::HashSet;
+    use sudo_common::context::Configuration;
+
+    use crate::environment::{is_safe_tz, should_keep, PATH_ZONEINFO};
+
+    struct TestConfiguration {
+        keep: HashSet<String>,
+        check: HashSet<String>,
+    }
+
+    impl Configuration for TestConfiguration {
+        fn env_keep(&self) -> &HashSet<String> {
+            &self.keep
+        }
+
+        fn env_check(&self) -> &HashSet<String> {
+            &self.check
+        }
+    }
+
+    #[test]
+    fn test_filtering() {
+        let config = TestConfiguration {
+            keep: HashSet::from(["AAP".to_string(), "NOOT".to_string(), "TZ".to_string()]),
+            check: HashSet::from(["MIES".to_string()]),
+        };
+
+        assert_eq!(should_keep("AAP", "FOO", &config), true);
+        assert_eq!(should_keep("MIES", "BAR", &config), true);
+        assert_eq!(should_keep("AAP", "()=foo", &config), false);
+        assert_eq!(should_keep("TZ", "Europe/Amsterdam", &config), true);
+        assert_eq!(should_keep("TZ", "../Europe/Berlin", &config), false);
+        assert_eq!(should_keep("MIES", "FOO/BAR", &config), false);
+        assert_eq!(should_keep("MIES", "FOO%", &config), false);
+    }
 
     #[test]
     fn test_tzinfo() {
@@ -173,5 +206,9 @@ mod tests {
             is_safe_tz(format!("{PATH_ZONEINFO}/../Europe/London").as_str()),
             false
         );
+        assert_eq!(
+            is_safe_tz(format!("{PATH_ZONEINFO}/../Europe/London").as_str()),
+            false
+        );
     }
 }
diff --git a/lib/sudo-env/src/lib.rs b/lib/sudo-env/src/lib.rs
new file mode 100644
index 00000000..c64c41aa
--- /dev/null
+++ b/lib/sudo-env/src/lib.rs
@@ -0,0 +1,4 @@
+#![forbid(unsafe_code)]
+
+pub mod environment;
+pub mod wildcard_match;
diff --git a/lib/sudo-exec/Cargo.toml b/lib/sudo-exec/Cargo.toml
new file mode 100644
index 00000000..377d6027
--- /dev/null
+++ b/lib/sudo-exec/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "sudo-exec"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+homepage.workspace = true
+publish.workspace = true
+
+[dependencies]
+signal-hook = { workspace = true, features = [ "extended-siginfo" ] }
+sudo-common.workspace = true 
+sudo-system.workspace = true 
diff --git a/lib/sudo-exec/src/lib.rs b/lib/sudo-exec/src/lib.rs
new file mode 100644
index 00000000..a3a6add6
--- /dev/null
+++ b/lib/sudo-exec/src/lib.rs
@@ -0,0 +1,113 @@
+#![forbid(unsafe_code)]
+use std::{
+    ffi::c_int,
+    io,
+    os::unix::process::CommandExt,
+    process::{Command, ExitStatus},
+};
+
+use signal_hook::{
+    consts::*,
+    iterator::{exfiltrator::WithOrigin, SignalsInfo},
+    low_level::siginfo::{Cause, Origin, Sent},
+};
+use sudo_common::context::{Context, Environment};
+use sudo_system::{getpgid, kill};
+
+/// We do not handle `SIGKILL`, `SIGSTOP`, `SIGILL`, `SIGFPE` nor `SIGSEGV` because those should
+/// not be intercepted and replaced. according to `POSIX`.
+// FIXME: are we missing any signals? `SIGEMT`, `SIGLOST` and `SIGPWR` are not exposed by
+// `signal-hook`.
+const SIGNALS: &[c_int] = &[
+    SIGABRT, SIGALRM, SIGBUS, SIGCHLD, SIGCONT, SIGHUP, SIGINT, SIGPIPE, SIGPROF, SIGQUIT, SIGSYS,
+    SIGTERM, SIGTRAP, SIGTSTP, SIGTTIN, SIGTTOU, SIGURG, SIGUSR1, SIGUSR2, SIGVTALRM, SIGWINCH,
+    SIGXCPU, SIGXFSZ, SIGIO,
+];
+
+/// Based on `ogsudo`s `exec_nopty` function.
+pub fn run_command(ctx: Context<'_>, env: Environment) -> io::Result<ExitStatus> {
+    // FIXME: should we pipe the stdio streams?
+    let mut cmd = Command::new(ctx.command.command)
+        .args(ctx.command.arguments)
+        .uid(ctx.target_user.uid)
+        .gid(ctx.target_user.gid)
+        .env_clear()
+        .envs(env)
+        .spawn()?;
+
+    let cmd_pid = cmd.id() as i32;
+
+    let mut signals = SignalsInfo::<WithOrigin>::new(SIGNALS)?;
+
+    loop {
+        for Origin {
+            signal,
+            process,
+            cause,
+            ..
+        } in signals.pending()
+        {
+            match signal {
+                SIGCHLD => {
+                    // FIXME: check `handle_sigchld_nopty`
+                    cmd.kill()?;
+                }
+                SIGWINCH => {
+                    // FIXME: check `handle_sigwinch`
+                    cmd.kill()?;
+                }
+                SIGINT | SIGQUIT | SIGTSTP => {
+                    if cause != Cause::Sent(Sent::User) {
+                        continue;
+                    }
+
+                    if let Some(process) = process {
+                        if process.pid != 0 {
+                            if process.pid == cmd_pid {
+                                continue;
+                            }
+                            let process_grp = getpgid(process.pid);
+                            if process_grp != -1 {
+                                // FIXME: we should also check that the process group is not the
+                                // sudo PID.
+                                if process_grp == cmd_pid {
+                                    continue;
+                                }
+                            }
+                        }
+                    }
+                }
+                _ => {
+                    if cause == Cause::Sent(Sent::User) {
+                        if let Some(process) = process {
+                            if process.pid != 0 {
+                                if process.pid == cmd_pid {
+                                    continue;
+                                }
+                                let process_grp = getpgid(process.pid);
+                                if process_grp != -1 {
+                                    // FIXME: we should also check that the process group is not the
+                                    // sudo PID.
+                                    if process_grp == cmd_pid {
+                                        continue;
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+
+            if signal == SIGALRM {
+                // FIXME: check `terminate_command` to match behavior.
+                cmd.kill()?;
+            } else if kill(cmd_pid, signal) != 0 {
+                eprintln!("kill failed");
+            }
+        }
+
+        if let Some(code) = cmd.try_wait()? {
+            return Ok(code);
+        }
+    }
+}
diff --git a/lib/sudo-system/src/lib.rs b/lib/sudo-system/src/lib.rs
index 60c46254..862f278f 100644
--- a/lib/sudo-system/src/lib.rs
+++ b/lib/sudo-system/src/lib.rs
@@ -1,5 +1,12 @@
-use std::{ffi::CString, fs::OpenOptions, mem::MaybeUninit, os::fd::AsRawFd, path::PathBuf};
-
+use std::{
+    ffi::{c_int, CString},
+    fs::OpenOptions,
+    mem::MaybeUninit,
+    os::fd::AsRawFd,
+    path::PathBuf,
+};
+
+use libc::pid_t;
 pub use libc::PATH_MAX;
 use sudo_cutils::*;
 
@@ -15,6 +22,19 @@ pub fn hostname() -> String {
     }
 }
 
+/// Send a signal to a process.
+pub fn kill(pid: pid_t, signal: c_int) -> c_int {
+    // SAFETY: This function cannot cause UB even if `pid` is not a valid process ID or if
+    // `signal` is not a valid signal code.
+    unsafe { libc::kill(pid, signal) }
+}
+
+/// Get a process group ID.
+pub fn getpgid(pid: pid_t) -> pid_t {
+    // SAFETY: This function cannot cause UB even if `pid` is not a valid process ID
+    unsafe { libc::getpgid(pid) }
+}
+
 #[derive(Debug, Clone, PartialEq)]
 pub struct User {
     pub uid: libc::uid_t,
diff --git a/lib/sudoers/src/ast.rs b/lib/sudoers/src/ast.rs
index 879f01ef..1f56b4a4 100644
--- a/lib/sudoers/src/ast.rs
+++ b/lib/sudoers/src/ast.rs
@@ -39,15 +39,25 @@ pub struct RunAs {
 }
 
 /// Commands in /etc/sudoers can have attributes attached to them, such as NOPASSWD, NOEXEC, ...
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum Tag {
-    NoPasswd,
-    Timeout(i32),
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq, Eq))]
+pub struct Tag {
+    pub passwd: bool,
+    pub cwd: Option<ChDir>,
+}
+
+impl Default for Tag {
+    fn default() -> Tag {
+        Tag {
+            passwd: true,
+            cwd: None,
+        }
+    }
 }
 
 /// Commands with attached attributes.
 #[derive(Debug)]
-pub struct CommandSpec(pub Vec<Tag>, pub Spec<Command>, pub Sha2);
+pub struct CommandSpec(pub Tag, pub Spec<Command>, pub Sha2);
 
 /// The main AST object for one sudoer-permission line
 #[derive(Debug)]
@@ -229,13 +239,16 @@ impl Parse for RunAs {
     }
 }
 
-/// Implementing the trait Parse for `Meta<Tag>`. Wrapped in an own object to avoid
-/// conflicting with a generic parse definition for [Meta].
+/// Implementing the trait Parse for `Meta<flag>`. Wrapped in an own object to avoid
+/// conflicting with a potential future generic parse definition for [Meta].
 ///
 /// The reason for combining a parser for these two unrelated categories is that this is one spot
 /// where the sudoer grammar isn't nicely LL(1); so at the same place where "NOPASSWD" can appear,
 /// we could also see "ALL".
-struct MetaOrTag(Meta<Tag>);
+struct MetaOrTag(Meta<Modifier>);
+
+/// A `Modifier` is something that updates the `Tag`.
+pub type Modifier = Box<dyn Fn(&mut Tag)>;
 
 // note: at present, "ALL" can be distinguished from a tag using a lookup of 1, since no tag starts with an "A"; but this feels like hanging onto
 // the parseability by a thread (although the original sudo also has some ugly parts, like 'sha224' being an illegal user name).
@@ -244,19 +257,24 @@ struct MetaOrTag(Meta<Tag>);
 impl Parse for MetaOrTag {
     fn parse(stream: &mut impl CharStream) -> Parsed<Self> {
         use Meta::*;
-        use Tag::*;
         let Upper(keyword) = try_nonterminal(stream)?;
-        let result = match keyword.as_str() {
-            "NOPASSWD" => NoPasswd,
-            "TIMEOUT" => {
+
+        let mut switch = |modifier: fn(&mut Tag)| {
+            expect_syntax(':', stream)?;
+            make(Box::new(modifier))
+        };
+
+        let result: Modifier = match keyword.as_str() {
+            "PASSWD" => switch(|tag| tag.passwd = true)?,
+            "NOPASSWD" => switch(|tag| tag.passwd = false)?,
+            "CWD" => {
                 expect_syntax('=', stream)?;
-                let Decimal(t) = expect_nonterminal(stream)?;
-                return make(MetaOrTag(Only(Timeout(t))));
+                let path: ChDir = expect_nonterminal(stream)?;
+                Box::new(move |tag| tag.cwd = Some(path.clone()))
             }
             "ALL" => return make(MetaOrTag(All)),
             alias => return make(MetaOrTag(Alias(alias.to_string()))),
         };
-        expect_syntax(':', stream)?;
 
         make(MetaOrTag(Only(result)))
     }
@@ -264,23 +282,25 @@ impl Parse for MetaOrTag {
 
 /// grammar:
 /// ```text
-/// commandspec = [tags]*, command
+/// commandspec = [tag modifiers]*, command
 /// ```
 
-impl Parse for CommandSpec {
+pub struct ProtoCommandSpec(Vec<Modifier>, Spec<Command>, Sha2);
+
+impl Parse for ProtoCommandSpec {
     fn parse(stream: &mut impl CharStream) -> Parsed<Self> {
         let no_hash = Sha2(Box::default());
-        let mut tags = Vec::new();
+        let mut tags = vec![];
         while let Some(MetaOrTag(keyword)) = try_nonterminal(stream)? {
             use Qualified::Allow;
             match keyword {
-                Meta::Only(tag) => tags.push(tag),
-                Meta::All => return make(CommandSpec(tags, Allow(Meta::All), no_hash)),
+                Meta::Only(modifier) => tags.push(modifier),
+                Meta::All => return make(ProtoCommandSpec(tags, Allow(Meta::All), no_hash)),
                 Meta::Alias(name) => {
-                    return make(CommandSpec(tags, Allow(Meta::Alias(name)), no_hash))
+                    return make(ProtoCommandSpec(tags, Allow(Meta::Alias(name)), no_hash))
                 }
             }
-            if tags.len() > CommandSpec::LIMIT {
+            if tags.len() > Identifier::LIMIT {
                 unrecoverable!(stream, "too many tags for command specifier")
             }
         }
@@ -307,11 +327,30 @@ impl Parse for CommandSpec {
 
         let cmd: Spec<Command> = expect_nonterminal(stream)?;
 
-        make(CommandSpec(tags, cmd, digest))
+        make(ProtoCommandSpec(tags, cmd, digest))
     }
 }
 
-impl Many for CommandSpec {}
+/// A manual implementation (instead of using Many) to chain Tag's together.
+impl Parse for Vec<CommandSpec> {
+    fn parse(stream: &mut impl CharStream) -> Parsed<Self> {
+        impl Many for ProtoCommandSpec {}
+        let cmdspecs = try_nonterminal::<Vec<ProtoCommandSpec>>(stream)?;
+
+        let mut tag = Default::default();
+        let chained_specs = cmdspecs
+            .into_iter()
+            .map(|ProtoCommandSpec(modifiers, cmd, digest)| {
+                for f in modifiers {
+                    f(&mut tag);
+                }
+                CommandSpec(tag.clone(), cmd, digest)
+            })
+            .collect();
+
+        make(chained_specs)
+    }
+}
 
 /// Parsing for a tuple of hostname, runas specifier and commandspec.
 /// grammar:
@@ -616,7 +655,7 @@ impl<T> Tagged<T> for Spec<T> {
 /// Special implementation for [CommandSpec]
 
 impl Tagged<Command> for CommandSpec {
-    type Flags = Vec<Tag>;
+    type Flags = Tag;
     fn into(&self) -> &Spec<Command> {
         &self.1
     }
diff --git a/lib/sudoers/src/ast_names.rs b/lib/sudoers/src/ast_names.rs
index 9ae1cfb5..8935e19d 100644
--- a/lib/sudoers/src/ast_names.rs
+++ b/lib/sudoers/src/ast_names.rs
@@ -83,6 +83,14 @@ mod names {
     impl UserFriendly for tokens::Sha2 {
         const DESCRIPTION: &'static str = "digest";
     }
+
+    impl UserFriendly for ProtoCommandSpec {
+        const DESCRIPTION: &'static str = CommandSpec::DESCRIPTION;
+    }
+
+    impl UserFriendly for tokens::ChDir {
+        const DESCRIPTION: &'static str = "directory or `*'";
+    }
 }
 
 #[cfg(test)]
diff --git a/lib/sudoers/src/basic_parser.rs b/lib/sudoers/src/basic_parser.rs
index e7ce565a..eb9715c8 100644
--- a/lib/sudoers/src/basic_parser.rs
+++ b/lib/sudoers/src/basic_parser.rs
@@ -273,7 +273,11 @@ impl<T: Parse> Parse for Option<T> {
 }
 
 /// Parsing method for lists of items separated by a given character; this adheres to the contract of the [Parse] trait.
-fn parse_list<T: Parse>(sep_by: char, max: usize, stream: &mut impl CharStream) -> Parsed<Vec<T>>
+pub(crate) fn parse_list<T: Parse>(
+    sep_by: char,
+    max: usize,
+    stream: &mut impl CharStream,
+) -> Parsed<Vec<T>>
 where
     T: Parse + UserFriendly,
 {
diff --git a/lib/sudoers/src/lib.rs b/lib/sudoers/src/lib.rs
index 8990ab3d..5d4c5cad 100644
--- a/lib/sudoers/src/lib.rs
+++ b/lib/sudoers/src/lib.rs
@@ -41,7 +41,7 @@ pub struct Request<'a, User: UnixUser, Group: UnixGroup> {
 /// (this is currently a stub and should later be turned into a trait and moved to a sudo-policies crate)
 #[derive(Debug)]
 pub struct Policy {
-    flags: Option<Vec<Tag>>,
+    flags: Option<Tag>,
     pub settings: Settings, // TODO: hide behind interface
 }
 
@@ -53,8 +53,8 @@ pub enum Authorization {
 
 impl Policy {
     pub fn authorization(&self) -> Authorization {
-        if let Some(vec) = &self.flags {
-            if vec.contains(&Tag::NoPasswd) {
+        if let Some(tag) = &self.flags {
+            if !tag.passwd {
                 Authorization::Passed
             } else {
                 Authorization::Required
@@ -127,7 +127,7 @@ fn check_permission<User: UnixUser + PartialEq<User>, Group: UnixGroup>(
     am_user: &User,
     on_host: &str,
     request: Request<User, Group>,
-) -> Option<Vec<Tag>> {
+) -> Option<Tag> {
     let cmdline = (request.command, request.arguments);
 
     let user_aliases = get_aliases(&aliases.user, &match_user(am_user));
diff --git a/lib/sudoers/src/tokens.rs b/lib/sudoers/src/tokens.rs
index 7a3c0a3b..1bb9e07e 100644
--- a/lib/sudoers/src/tokens.rs
+++ b/lib/sudoers/src/tokens.rs
@@ -251,6 +251,41 @@ impl Token for StringParameter {
     }
 }
 
+// a path used for in CWD and CHROOT specs
+#[derive(Clone, Debug)]
+#[cfg_attr(test, derive(PartialEq, Eq))]
+pub enum ChDir {
+    Path(String),
+    Asterisk,
+}
+
+impl Token for ChDir {
+    const MAX_LEN: usize = 1024;
+
+    fn construct(s: String) -> Result<Self, String> {
+        if s == "*" {
+            Ok(ChDir::Asterisk)
+        } else if s.contains('*') {
+            Err("path cannot contain `*'".to_string())
+        } else {
+            Ok(ChDir::Path(s))
+        }
+    }
+
+    fn accept(c: char) -> bool {
+        !c.is_control() && !Self::escaped(c)
+    }
+
+    fn accept_1st(c: char) -> bool {
+        "~/*".contains(c)
+    }
+
+    const ESCAPE: char = '\\';
+    fn escaped(c: char) -> bool {
+        "\\\" ".contains(c)
+    }
+}
+
 /// A digest specifier; note that the type of hash is implied by the length; if sudo would support
 /// multiple hashes with the same hash length, this needs to be recorded explicity.
 #[derive(Debug)]
diff --git a/sudo/Cargo.toml b/sudo/Cargo.toml
index 1d14c061..d3453af6 100644
--- a/sudo/Cargo.toml
+++ b/sudo/Cargo.toml
@@ -9,8 +9,10 @@ publish.workspace = true
 categories = ["command-line-interface"]
 
 [dependencies]
+sudo-env.workspace = true
 sudo-common.workspace = true
 sudo-system.workspace = true
 sudo-cli.workspace = true
 sudoers.workspace = true
 sudo-pam.workspace = true
+sudo-exec.workspace = true
diff --git a/sudo/src/main.rs b/sudo/src/main.rs
index 667d8afb..fc739288 100644
--- a/sudo/src/main.rs
+++ b/sudo/src/main.rs
@@ -1,9 +1,16 @@
 #![forbid(unsafe_code)]
 
+use pam::authenticate;
 use sudo_cli::SudoOptions;
-use sudo_common::{context::Context, env::Environment, error::Error, pam::authenticate};
+use sudo_common::{
+    context::{Context, Environment},
+    error::Error,
+};
+use sudo_env::environment;
 use sudoers::{Authorization, Sudoers};
 
+mod pam;
+
 fn parse_sudoers() -> Result<Sudoers, Error> {
     // TODO: move to global configuration
     let sudoers_path = "/etc/sudoers.test";
@@ -36,6 +43,24 @@ fn main() -> Result<(), Error> {
     // parse cli options
     let sudo_options = SudoOptions::parse();
 
+    let check_var = std::env::var("SUDO_RS_IS_UNSTABLE").unwrap_or_else(|_| "".to_string());
+
+    if check_var != "I accept that my system may break unexpectedly" {
+        eprintln!("WARNING!");
+        eprintln!("Sudo-rs is in the early stages of development and could potentially break your system.");
+        eprintln!(
+            "We recommend that you do not run this on any production environment. To turn off this"
+        );
+        eprintln!(
+            "warning and start using sudo-rs set the environment variable SUDO_RS_IS_UNSTABLE to"
+        );
+        eprintln!(
+            "the value `I accept that my system may break unexpectedly`. If you are unsure how to"
+        );
+        eprintln!("do this then this software is not suited for you at this time.");
+        std::process::exit(1);
+    }
+
     // parse sudoers file
     let sudoers = parse_sudoers()?;
 
@@ -56,10 +81,10 @@ fn main() -> Result<(), Error> {
         }
     };
 
-    let context = context.with_filtered_env(current_env, &policy.settings);
+    let target_env = environment::get_target_environment(current_env, &context, &policy.settings);
 
     // run command and return corresponding exit code
-    match sudo_common::exec::exec(context) {
+    match sudo_exec::run_command(context, target_env) {
         Ok(status) => {
             if let Some(code) = status.code() {
                 std::process::exit(code);
diff --git a/lib/sudo-common/src/pam.rs b/sudo/src/pam.rs
similarity index 91%
rename from lib/sudo-common/src/pam.rs
rename to sudo/src/pam.rs
index 5105de1c..c208bd46 100644
--- a/lib/sudo-common/src/pam.rs
+++ b/sudo/src/pam.rs
@@ -1,4 +1,4 @@
-use crate::error::Error;
+use sudo_common::error::Error;
 
 pub fn authenticate(username: &str) -> Result<(), Error> {
     let mut pam = sudo_pam::PamContext::builder_cli()


Test patch:
diff --git a/lib/sudo-common/src/wildcard_match.rs b/lib/sudo-env/src/wildcard_match.rs
similarity index 100%
rename from lib/sudo-common/src/wildcard_match.rs
rename to lib/sudo-env/src/wildcard_match.rs
diff --git a/lib/sudo-common/tests/env_tests.rs b/lib/sudo-env/tests/env_tests.rs
similarity index 95%
rename from lib/sudo-common/tests/env_tests.rs
rename to lib/sudo-env/tests/env_tests.rs
index 6d0b852f..a8b2237f 100644
--- a/lib/sudo-common/tests/env_tests.rs
+++ b/lib/sudo-env/tests/env_tests.rs
@@ -1,9 +1,7 @@
 use std::collections::HashSet;
 use sudo_cli::SudoOptions;
-use sudo_common::{
-    context::{CommandAndArguments, Context},
-    env::{get_target_environment, Environment},
-};
+use sudo_common::context::{CommandAndArguments, Context, Environment};
+use sudo_env::environment::get_target_environment;
 use sudo_system::{Group, User};
 use sudoers::Settings;
 
@@ -34,6 +32,9 @@ const TESTS: &str = "
     SUDO_GID=1000
     SUDO_UID=1000
     SUDO_USER=test
+    HOME=/root
+    LOGNAME=root
+    USER=root
     TERM=xterm
 > sudo -u test env
     HOSTNAME=test-ubuntu
@@ -48,6 +49,9 @@ const TESTS: &str = "
     SUDO_GID=1000
     SUDO_UID=1000
     SUDO_USER=test
+    HOME=/home/test
+    LOGNAME=test
+    USER=test
     TERM=xterm
 ";
 
diff --git a/lib/sudoers/src/test/mod.rs b/lib/sudoers/src/test/mod.rs
index 95c269a5..1da26646 100644
--- a/lib/sudoers/src/test/mod.rs
+++ b/lib/sudoers/src/test/mod.rs
@@ -3,6 +3,16 @@ use crate::ast;
 use basic_parser::{parse_eval, parse_lines, parse_string};
 use std::iter;
 
+// parsing a single CommandSpec is useful in some tests
+impl basic_parser::Parse for CommandSpec {
+    fn parse(stream: &mut impl basic_parser::CharStream) -> basic_parser::Parsed<Self> {
+        let vec: Vec<_> = basic_parser::try_nonterminal(stream)?;
+        assert_eq!(vec.len(), 1);
+        let result = vec.into_iter().next().unwrap();
+        Ok(result)
+    }
+}
+
 #[derive(PartialEq)]
 struct Named(&'static str);
 
@@ -131,13 +141,16 @@ fn permission_test() {
     }
 
     macro_rules! pass {
-        ([$($sudo:expr),*], $user:expr => $req:expr, $server:expr; $command:expr $(=> [$($list:expr),*])?) => {
+        ([$($sudo:expr),*], $user:expr => $req:expr, $server:expr; $command:expr $(=> [$($key:ident : $val:expr),*])?) => {
             let (Sudoers { rules,aliases,settings }, _) = analyze(sudoer![$($sudo),*]);
             let cmdvec = $command.split_whitespace().collect::<Vec<_>>();
             let req = Request { user: $req.0, group: $req.1, command: &cmdvec[0].as_ref(), arguments: &cmdvec[1..].join(" ") };
             let result = check_permission(&Sudoers { rules, aliases, settings }, &Named($user), $server, req);
-            $(assert_eq!(result, Some(vec![$($list),*]));)?
             assert!(!result.is_none());
+            $(
+                let result = result.unwrap();
+                $(assert_eq!(result.$key, $val);)*
+            )?
         }
     }
     macro_rules! SYNTAX {
@@ -146,15 +159,23 @@ fn permission_test() {
         };
     }
 
-    use crate::ast::Tag::*;
-
     SYNTAX!(["ALL ALL = (;) ALL"]);
     FAIL!(["user ALL=(ALL:ALL) ALL"], "nobody"    => root(), "server"; "/bin/hello");
     pass!(["user ALL=(ALL:ALL) ALL"], "user"      => root(), "server"; "/bin/hello");
-    pass!(["user ALL=(ALL:ALL) /bin/foo"], "user" => root(), "server"; "/bin/foo");
+    pass!(["user ALL=(ALL:ALL) /bin/foo"], "user" => root(), "server"; "/bin/foo" => [passwd: true]);
     FAIL!(["user ALL=(ALL:ALL) /bin/foo"], "user" => root(), "server"; "/bin/hello");
-    pass!(["user ALL=(ALL:ALL) /bin/foo, NOPASSWD: /bin/bar"], "user" => root(), "server"; "/bin/foo");
-    pass!(["user ALL=(ALL:ALL) /bin/foo, NOPASSWD: /bin/bar"], "user" => root(), "server"; "/bin/bar" => [NoPasswd]);
+    pass!(["user ALL=(ALL:ALL) PASSWD: /bin/foo"], "user" => root(), "server"; "/bin/foo" => [passwd: true]);
+    pass!(["user ALL=(ALL:ALL) NOPASSWD: PASSWD: /bin/foo"], "user" => root(), "server"; "/bin/foo" => [passwd: true]);
+    pass!(["user ALL=(ALL:ALL) PASSWD: NOPASSWD: /bin/foo"], "user" => root(), "server"; "/bin/foo" => [passwd: false]);
+    pass!(["user ALL=(ALL:ALL) /bin/foo, NOPASSWD: /bin/bar"], "user" => root(), "server"; "/bin/foo" => [passwd: true]);
+    pass!(["user ALL=(ALL:ALL) /bin/foo, NOPASSWD: /bin/bar"], "user" => root(), "server"; "/bin/bar" => [passwd: false]);
+    pass!(["user ALL=(ALL:ALL) NOPASSWD: /bin/foo, /bin/bar"], "user" => root(), "server"; "/bin/bar" => [passwd: false]);
+    pass!(["user ALL=(ALL:ALL) CWD=/ /bin/foo, /bin/bar"], "user" => root(), "server"; "/bin/bar" => [cwd: Some(ChDir::Path("/".to_string()))]);
+    pass!(["user ALL=(ALL:ALL) CWD=/ /bin/foo, CWD=* /bin/bar"], "user" => root(), "server"; "/bin/bar" => [cwd: Some(ChDir::Asterisk)]);
+    pass!(["user ALL=(ALL:ALL) CWD=/bin CWD=* /bin/foo"], "user" => root(), "server"; "/bin/foo" => [cwd: Some(ChDir::Asterisk)]);
+    pass!(["user ALL=(ALL:ALL) CWD=/usr/bin NOPASSWD: /bin/foo"], "user" => root(), "server"; "/bin/foo" => [passwd: false, cwd: Some(ChDir::Path("/usr/bin".to_string()))]);
+    //note: original sudo does not allow the below
+    pass!(["user ALL=(ALL:ALL) NOPASSWD: CWD=/usr/bin /bin/foo"], "user" => root(), "server"; "/bin/foo" => [passwd: false, cwd: Some(ChDir::Path("/usr/bin".to_string()))]);
 
     pass!(["user ALL=/bin/e##o"], "user" => root(), "vm"; "/bin/e");
     SYNTAX!(["ALL ALL=(ALL) /bin/\n/echo"]);
diff --git a/test-framework/sudo-compliance-tests/src/env_reset.rs b/test-framework/sudo-compliance-tests/src/env_reset.rs
index bf42d1e5..843083a2 100644
--- a/test-framework/sudo-compliance-tests/src/env_reset.rs
+++ b/test-framework/sudo-compliance-tests/src/env_reset.rs
@@ -9,7 +9,6 @@ use crate::{Result, SUDOERS_ROOT_ALL_NOPASSWD};
 
 // see 'environment' section in`man sudo`
 // see 'command environment' section in`man sudoers`
-#[ignore]
 #[test]
 fn vars_set_by_sudo_in_env_reset_mode() -> Result<()> {
     let env = EnvBuilder::default()
@@ -24,7 +23,7 @@ fn vars_set_by_sudo_in_env_reset_mode() -> Result<()> {
 
     // run sudo in an empty environment
     let stdout = env.stdout(
-        &["env", "-i", &sudo_abs_path, &env_abs_path],
+        &["env", "-i", "SUDO_RS_IS_UNSTABLE=I accept that my system may break unexpectedly", &sudo_abs_path, &env_abs_path],
         As::Root,
         None,
     )?;
@@ -77,7 +76,6 @@ fn vars_set_by_sudo_in_env_reset_mode() -> Result<()> {
     Ok(())
 }
 
-#[ignore]
 #[test]
 fn env_reset_mode_clears_env_vars() -> Result<()> {
     let env = EnvBuilder::default()
diff --git a/test-framework/sudo-compliance-tests/src/nopasswd.rs b/test-framework/sudo-compliance-tests/src/nopasswd.rs
index 163edd0c..230a6f32 100644
--- a/test-framework/sudo-compliance-tests/src/nopasswd.rs
+++ b/test-framework/sudo-compliance-tests/src/nopasswd.rs
@@ -30,7 +30,6 @@ fn can_sudo_as_root_without_providing_a_password_if_roots_group_is_in_sudoers_fi
     Ok(())
 }
 
-#[ignore]
 #[test]
 fn can_sudo_as_user_without_providing_a_password_if_users_group_is_in_sudoers_file_and_nopasswd_is_set(
 ) -> Result<()> {
diff --git a/test-framework/sudo-test/src/ours.Dockerfile b/test-framework/sudo-test/src/ours.Dockerfile
index a1d3f044..3907aa40 100644
--- a/test-framework/sudo-test/src/ours.Dockerfile
+++ b/test-framework/sudo-test/src/ours.Dockerfile
@@ -12,3 +12,5 @@ RUN install --mode 4755 target/debug/sudo /usr/bin/sudo
 RUN apt-get autoremove -y clang libclang-dev
 # HACK sudo-rs is hard-coded to use /etc/sudoers.test
 RUN ln -s sudoers /etc/sudoers.test
+# Makes sure our sudo implementation actually runs
+ENV SUDO_RS_IS_UNSTABLE="I accept that my system may break unexpectedly"