Skip to main content

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}