1//! Linux Landlock sandbox implementation.
2//!
3//! Uses the Landlock LSM to restrict filesystem access for the current process.
4//! Must be called after fork(), before exec().
5
6use landlock::{
7 ABI, Access, AccessFs, AccessNet, PathBeneath, PathFd, Ruleset, RulesetAttr,
8 RulesetCreatedAttr, RulesetStatus,
9};
10use std::io::{Error, Result};
11use std::path::Path;
12
13use crate::terminal_settings::SandboxConfig;
14
15const TARGET_ABI: ABI = ABI::V5;
16
17fn fs_read() -> AccessFs {
18 AccessFs::ReadFile | AccessFs::ReadDir
19}
20
21fn fs_read_exec() -> AccessFs {
22 fs_read() | AccessFs::Execute
23}
24
25fn fs_all() -> AccessFs {
26 AccessFs::from_all(TARGET_ABI)
27}
28
29fn add_path_rule(
30 ruleset: landlock::RulesetCreated,
31 path: &Path,
32 access: AccessFs,
33) -> std::result::Result<landlock::RulesetCreated, landlock::RulesetError> {
34 match PathFd::new(path) {
35 Ok(fd) => ruleset.add_rule(PathBeneath::new(fd, access)),
36 Err(e) => {
37 // Path doesn't exist — skip it (e.g., /opt/homebrew on non-Homebrew systems)
38 log::debug!(
39 "Landlock: skipping nonexistent path {}: {e}",
40 path.display()
41 );
42 Ok(ruleset)
43 }
44 }
45}
46
47/// Apply a Landlock sandbox to the current process.
48/// Must be called after fork(), before exec().
49pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> {
50 // PR_SET_NO_NEW_PRIVS is required before landlock_restrict_self.
51 // It prevents the process from gaining privileges via setuid binaries.
52 let ret = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
53 if ret != 0 {
54 return Err(Error::last_os_error());
55 }
56
57 let ruleset_base = Ruleset::default()
58 .handle_access(AccessFs::from_all(TARGET_ABI))
59 .map_err(|e| Error::other(format!("landlock ruleset create: {e}")))?;
60
61 let ruleset_with_net = if !config.allow_network {
62 ruleset_base
63 .handle_access(AccessNet::from_all(TARGET_ABI))
64 .map_err(|e| {
65 Error::other(format!(
66 "landlock network restriction not supported (requires kernel 6.4+): {e}"
67 ))
68 })?
69 } else {
70 ruleset_base
71 };
72
73 let mut ruleset = ruleset_with_net
74 .create()
75 .map_err(|e| Error::other(format!("landlock ruleset init: {e}")))?;
76
77 // System executable paths (read + execute)
78 for path in &config.system_paths.executable {
79 ruleset = add_path_rule(ruleset, path, fs_read_exec())
80 .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
81 }
82
83 // System read-only paths
84 for path in &config.system_paths.read_only {
85 ruleset = add_path_rule(ruleset, path, fs_read())
86 .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
87 }
88
89 // System read+write paths
90 for path in &config.system_paths.read_write {
91 ruleset = add_path_rule(ruleset, path, fs_all())
92 .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
93 }
94
95 // Project directory: full access
96 ruleset = add_path_rule(ruleset, &config.project_dir, fs_all())
97 .map_err(|e| Error::other(format!("landlock project rule: {e}")))?;
98
99 // User-configured paths
100 for path in &config.additional_executable_paths {
101 ruleset = add_path_rule(ruleset, path, fs_read_exec())
102 .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
103 }
104 for path in &config.additional_read_only_paths {
105 ruleset = add_path_rule(ruleset, path, fs_read())
106 .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
107 }
108 for path in &config.additional_read_write_paths {
109 ruleset = add_path_rule(ruleset, path, fs_all())
110 .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
111 }
112
113 // Shell config dotfiles: read-only
114 if let Ok(home) = std::env::var("HOME") {
115 let home = Path::new(&home);
116 for dotfile in &[
117 ".bashrc",
118 ".bash_profile",
119 ".bash_login",
120 ".profile",
121 ".zshrc",
122 ".zshenv",
123 ".zprofile",
124 ".zlogin",
125 ".zlogout",
126 ".inputrc",
127 ".terminfo",
128 ".gitconfig",
129 ] {
130 let path = home.join(dotfile);
131 if path.exists() {
132 ruleset = add_path_rule(ruleset, &path, fs_read())
133 .map_err(|e| Error::other(format!("landlock dotfile rule: {e}")))?;
134 }
135 }
136 let config_dir = home.join(".config");
137 if config_dir.exists() {
138 ruleset = add_path_rule(ruleset, &config_dir, fs_read())
139 .map_err(|e| Error::other(format!("landlock .config rule: {e}")))?;
140 }
141 // /proc/self for bash process substitution
142 let proc_self = Path::new("/proc/self");
143 if proc_self.exists() {
144 ruleset = add_path_rule(ruleset, proc_self, fs_read())
145 .map_err(|e| Error::other(format!("landlock /proc/self rule: {e}")))?;
146 }
147 }
148
149 let status = ruleset
150 .restrict_self()
151 .map_err(|e| Error::other(format!("landlock restrict_self: {e}")))?;
152
153 match status.ruleset {
154 RulesetStatus::FullyEnforced => {
155 log::info!("Landlock sandbox fully enforced");
156 }
157 RulesetStatus::PartiallyEnforced => {
158 if !config.allow_network {
159 log::warn!(
160 "Landlock sandbox partially enforced; \
161 network restriction may not be enforced on this kernel"
162 );
163 } else {
164 log::warn!("Landlock sandbox partially enforced (older kernel ABI)");
165 }
166 }
167 RulesetStatus::NotEnforced => {
168 if !config.allow_network {
169 return Err(Error::other(
170 "Landlock not supported on this kernel but network restriction was requested",
171 ));
172 }
173 log::warn!("Landlock not supported on this kernel; running unsandboxed");
174 }
175 }
176
177 Ok(())
178}