murk_cli/hardening.rs
1//! Process hardening: best-effort defense-in-depth measures.
2
3/// Disable core dumps for this process.
4///
5/// Sets `RLIMIT_CORE` to 0 on Unix. A core file written after a crash while
6/// murk holds decrypted secret material is the worst possible leak — disabling
7/// it up front removes the failure mode regardless of system defaults, which
8/// vary across Linux distros, macOS, and BSDs.
9///
10/// Best-effort: a failed syscall is swallowed rather than blocking the
11/// command. Idempotent — a second call on a process that's already at zero is
12/// a no-op. On non-Unix targets this is a no-op.
13pub fn disable_core_dumps() {
14 #[cfg(unix)]
15 {
16 let limit = libc::rlimit {
17 rlim_cur: 0,
18 rlim_max: 0,
19 };
20 // SAFETY: setrlimit reads through the pointer for the duration of
21 // the call; `limit` lives for that scope. Return code is intentionally
22 // ignored — see the doc comment.
23 unsafe {
24 libc::setrlimit(libc::RLIMIT_CORE, &raw const limit);
25 }
26 }
27}
28
29/// The effective strict setting: strict is ON when either the operator set a
30/// truthy `MURK_STRICT` or this is an [`agent_context`] (`MURK_AGENT`). There is
31/// deliberately no way to turn strict OFF from inside an agent context — an
32/// operator who wants convenience simply doesn't opt into agent context. Strict
33/// mode trades convenience for a safer default: don't write a secret to disk (see
34/// [`is_ram_backed`]) and don't fall back to the operator's stored key (see
35/// `env::resolve_key_with_source`). This is the toggle the strict gates read.
36pub fn strict_mode() -> bool {
37 effective_strict_from(
38 &std::env::var("MURK_STRICT").unwrap_or_default(),
39 &std::env::var("MURK_AGENT").unwrap_or_default(),
40 )
41}
42
43/// Whether murk is running on behalf of an AI agent, via the explicit
44/// `MURK_AGENT` opt-in. Agent context forces strict mode (see [`strict_mode`]) so
45/// the honest path never falls back to the operator's stored key. `murk agent
46/// exec` sets it (alongside `MURK_STRICT`) for the child. This is a safe default,
47/// not a sandbox: a child that controls its own environment or can read
48/// `~/.config/murk/keys` directly is outside murk's boundary — real containment
49/// is OS-level isolation (see the note in `docs/ai-agents.md`).
50pub fn agent_context() -> bool {
51 strict_from(&std::env::var("MURK_AGENT").unwrap_or_default())
52}
53
54/// Whether murk appears to be running in CI (the conventional `CI` variable set
55/// truthy). Advisory only: CI context drives a nudge toward the scoped agent
56/// path but — unlike [`agent_context`] — does not by itself flip strict mode, so
57/// existing pipelines are never silently changed.
58pub fn ci_context() -> bool {
59 strict_from(&std::env::var("CI").unwrap_or_default())
60}
61
62/// Whether the operator opted into self-scoping: honoring the vault's agent
63/// allow-tag policy for their OWN key, as if they were an agent. On via an
64/// explicit `MURK_SELF_SCOPE`, or implicitly in an [`agent_context`] (declaring
65/// `MURK_AGENT` binds you to the policy even with your own key). A no-op on a
66/// vault with no policy set.
67pub fn self_scope() -> bool {
68 strict_from(&std::env::var("MURK_SELF_SCOPE").unwrap_or_default()) || agent_context()
69}
70
71/// Truthy values: `1`, `true`, `yes` (case-insensitive, trimmed). Split out so
72/// the rules are testable without mutating process-global env state.
73fn strict_from(val: &str) -> bool {
74 matches!(
75 val.trim().to_ascii_lowercase().as_str(),
76 "1" | "true" | "yes"
77 )
78}
79
80/// Pure effective-strict decision from raw env values, split out for testing.
81/// Strict is on when `MURK_STRICT` is truthy OR `MURK_AGENT` (agent context) is
82/// truthy; agent context cannot be overridden off.
83fn effective_strict_from(strict: &str, agent: &str) -> bool {
84 strict_from(strict) || strict_from(agent)
85}
86
87/// Whether `path` lives on a RAM-backed filesystem (tmpfs/ramfs), meaning data
88/// written there never hits persistent storage.
89///
90/// Used to fail closed in strict mode before `murk edit` writes a decrypted
91/// secret to a scratch file: a best-effort overwrite-and-unlink can't undo a
92/// write to a journaled or copy-on-write disk, and editors leave their own swap
93/// files behind, so the only real guarantee is to never write to disk at all.
94///
95/// On Linux a tmpfs/ramfs mount is identified by its `statfs` magic number. On
96/// macOS there is no tmpfs by default, so this returns `false` for the usual
97/// `/tmp` — which is the honest answer. On non-Unix targets this returns
98/// `false` (assume disk-backed; we can't prove otherwise).
99pub fn is_ram_backed(path: &std::path::Path) -> bool {
100 #[cfg(target_os = "linux")]
101 {
102 use std::os::unix::ffi::OsStrExt;
103 // TMPFS_MAGIC and RAMFS_MAGIC from <linux/magic.h>.
104 const TMPFS_MAGIC: libc::c_long = 0x0102_1994;
105 const RAMFS_MAGIC: libc::c_long = 0x858_458f6_u64 as libc::c_long;
106
107 let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
108 return false;
109 };
110 // SAFETY: zeroed statfs is a valid initial state; statfs writes into it
111 // for the duration of the call, and `c_path` outlives the call.
112 let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
113 let rc = unsafe { libc::statfs(c_path.as_ptr(), &raw mut buf) };
114 rc == 0 && matches!(buf.f_type as libc::c_long, TMPFS_MAGIC | RAMFS_MAGIC)
115 }
116 #[cfg(target_os = "macos")]
117 {
118 use std::os::unix::ffi::OsStrExt;
119 let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
120 return false;
121 };
122 // SAFETY: as above; macOS statfs reports the filesystem type by name.
123 let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
124 let rc = unsafe { libc::statfs(c_path.as_ptr(), &raw mut buf) };
125 if rc != 0 {
126 return false;
127 }
128 // f_fstypename is a fixed-size [c_char] holding a NUL-terminated name.
129 let name: Vec<u8> = buf
130 .f_fstypename
131 .iter()
132 .take_while(|&&c| c != 0)
133 .map(|&c| c.cast_unsigned())
134 .collect();
135 name == b"tmpfs"
136 }
137 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
138 {
139 let _ = path;
140 false
141 }
142}
143
144/// Whether this process's stdout is a regular file (as opposed to a pipe,
145/// terminal, or device).
146///
147/// Strict mode uses this to catch `murk export > secrets.env` style redirects
148/// that would persist plaintext secrets to disk, while still allowing the
149/// `eval "$(murk export)"` pipe that direnv relies on. Unix-only; returns
150/// `false` elsewhere (can't determine — don't block).
151pub fn stdout_is_regular_file() -> bool {
152 #[cfg(unix)]
153 {
154 use std::os::unix::io::{AsRawFd, FromRawFd};
155 // Borrow stdout's fd as a File to read its metadata. ManuallyDrop keeps
156 // dropping the File from closing the real stdout — we only borrowed it.
157 let fd = std::io::stdout().as_raw_fd();
158 let f = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) });
159 f.metadata().is_ok_and(|m| m.is_file())
160 }
161 #[cfg(not(unix))]
162 {
163 false
164 }
165}
166
167#[cfg(all(test, unix))]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn disables_core_dumps() {
173 disable_core_dumps();
174
175 let mut current = libc::rlimit {
176 rlim_cur: 1,
177 rlim_max: 1,
178 };
179 // SAFETY: getrlimit writes into `&mut current` for the duration of
180 // the call.
181 let rc = unsafe { libc::getrlimit(libc::RLIMIT_CORE, &raw mut current) };
182 assert_eq!(rc, 0, "getrlimit failed");
183 assert_eq!(current.rlim_cur, 0);
184 assert_eq!(current.rlim_max, 0);
185 }
186
187 #[test]
188 fn strict_truthiness() {
189 for on in ["1", "true", "yes", "YES", " True ", "Yes"] {
190 assert!(strict_from(on), "{on:?} should enable strict mode");
191 }
192 for off in ["", "0", "false", "no", "off", "enabled", "2"] {
193 assert!(!strict_from(off), "{off:?} should not enable strict mode");
194 }
195 }
196
197 #[test]
198 fn effective_strict_from_decision_table() {
199 // (MURK_STRICT, MURK_AGENT, expected effective strict)
200 let cases = [
201 ("1", "", true),
202 ("yes", "", true),
203 ("", "1", true),
204 ("true", "true", true),
205 // Security-critical rows: agent context is NOT overridable —
206 // a truthy MURK_AGENT forces strict even when MURK_STRICT is
207 // explicitly falsy.
208 ("0", "1", true),
209 ("false", "1", true),
210 ("bogus", "1", true),
211 ("", "", false),
212 ("0", "", false),
213 ("", "0", false),
214 ("bogus", "", false),
215 ("2", "enabled", false),
216 ];
217 for (strict, agent, expected) in cases {
218 assert_eq!(
219 effective_strict_from(strict, agent),
220 expected,
221 "strict={strict:?} agent={agent:?}"
222 );
223 }
224 }
225
226 #[test]
227 fn nonexistent_path_is_not_ram_backed() {
228 // statfs fails on a missing path; we must not report it as RAM-backed.
229 assert!(!is_ram_backed(std::path::Path::new(
230 "/no/such/murk/path/exists"
231 )));
232 }
233
234 #[cfg(target_os = "linux")]
235 #[test]
236 fn dev_shm_is_ram_backed() {
237 let shm = std::path::Path::new("/dev/shm");
238 if shm.is_dir() {
239 assert!(is_ram_backed(shm), "/dev/shm should be tmpfs");
240 }
241 }
242
243 #[cfg(target_os = "macos")]
244 #[test]
245 fn macos_tmp_is_not_ram_backed() {
246 // macOS has no tmpfs by default; the disk-backed temp dir must read false.
247 assert!(!is_ram_backed(&std::env::temp_dir()));
248 }
249}