Skip to main content

lean_ctx/core/
workspace_trust.rs

1//! Workspace trust for project-local `.lean-ctx.toml` overrides (GH security
2//! audit, finding 4).
3//!
4//! A cloned repository ships its own `.lean-ctx.toml`. Through
5//! `Config::merge_local` that file can raise
6//! *security-sensitive* settings — replace the shell allowlist, widen the path
7//! jail (`allow_paths` / `extra_roots`), repoint the proxy upstream, define
8//! command aliases. Opening an untrusted clone with an agent would let the repo
9//! silently weaken lean-ctx's own boundaries before the user has read a line.
10//!
11//! Mirroring VS Code's *Workspace Trust*, project-local security-sensitive
12//! overrides are honoured only for a workspace the user has explicitly trusted
13//! via `lean-ctx trust`. Trust is pinned to BOTH the workspace path AND a content
14//! hash of its `.lean-ctx.toml`: editing the file after trust invalidates the
15//! pin, so a "trust once, modify later" change can never take effect silently.
16//!
17//! Comfort-only overrides (compression level, theme, memory tuning) are never
18//! gated — only the sensitive set listed in [`crate::core::config`] is withheld
19//! when the workspace is untrusted.
20
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24
25/// Env override: trust every workspace this process sees. Intended for headless,
26/// already-trusted environments (CI / fleet) where no human can answer a prompt.
27/// Accepts `1` / `true`.
28const TRUST_ALL_ENV: &str = "LEAN_CTX_TRUST_WORKSPACE";
29
30/// Env override: comma-separated absolute roots to treat as trusted. Intended
31/// for fleet provisioning where the set of trusted repos is managed centrally.
32const TRUSTED_ROOTS_ENV: &str = "LEAN_CTX_TRUSTED_ROOTS";
33
34pub(crate) const FILE_NAME: &str = "workspace-trust.toml";
35
36/// One trusted workspace: its canonical path plus the content hash of the
37/// `.lean-ctx.toml` reviewed at trust time (empty when no local file existed).
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct TrustedWorkspace {
40    /// Canonicalized absolute workspace root.
41    pub path: String,
42    /// blake3 hash of `.lean-ctx.toml` at trust time; empty = none present then.
43    pub config_hash: String,
44    /// When it was trusted (RFC 3339) — for the audit conversation, not enforcement.
45    pub added_at: String,
46}
47
48/// The pinned trust set, persisted as `workspace-trust.toml`.
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct TrustStore {
51    #[serde(default, rename = "workspace", skip_serializing_if = "Vec::is_empty")]
52    pub workspaces: Vec<TrustedWorkspace>,
53}
54
55/// Location of the trust file (`<config_dir>/workspace-trust.toml`).
56pub(crate) fn store_path() -> Result<PathBuf, String> {
57    Ok(crate::core::paths::config_dir()?.join(FILE_NAME))
58}
59
60/// Load the pinned set. A missing file is the common case and yields an empty
61/// store, never an error.
62pub fn load() -> Result<TrustStore, String> {
63    let path = store_path()?;
64    if !path.exists() {
65        return Ok(TrustStore::default());
66    }
67    let text =
68        std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
69    toml::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
70}
71
72/// Persist the pinned set (creating the config dir if needed), owner-only.
73pub(crate) fn save(store: &TrustStore) -> Result<(), String> {
74    let path = store_path()?;
75    if let Some(parent) = path.parent() {
76        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir config: {e}"))?;
77    }
78    let text = toml::to_string_pretty(store).map_err(|e| format!("serialize trust store: {e}"))?;
79    std::fs::write(&path, &text).map_err(|e| format!("write {}: {e}", path.display()))?;
80    restrict_permissions(&path);
81    Ok(())
82}
83
84#[cfg(unix)]
85fn restrict_permissions(path: &Path) {
86    use std::os::unix::fs::PermissionsExt;
87    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
88}
89
90#[cfg(not(unix))]
91fn restrict_permissions(_path: &Path) {}
92
93/// Canonicalize a root for stable comparison. Falls back to the lexical path
94/// when the dir can't be canonicalized (e.g. it no longer exists).
95fn canonical(root: &Path) -> String {
96    std::fs::canonicalize(root)
97        .unwrap_or_else(|_| root.to_path_buf())
98        .to_string_lossy()
99        .to_string()
100}
101
102/// Content hash of a workspace's `.lean-ctx.toml`, or empty when absent. This is
103/// the value pinned at trust time and re-checked on every load.
104#[must_use]
105pub(crate) fn config_hash_for(root: &Path) -> String {
106    let local = crate::core::config::Config::local_path(&root.to_string_lossy());
107    std::fs::read_to_string(&local)
108        .ok()
109        .map(|c| crate::core::hasher::hash_str(&c))
110        .unwrap_or_default()
111}
112
113fn now() -> String {
114    chrono::Utc::now().to_rfc3339()
115}
116
117fn env_trusted_roots() -> Vec<String> {
118    std::env::var(TRUSTED_ROOTS_ENV)
119        .ok()
120        .into_iter()
121        .flat_map(|v| {
122            v.split(',')
123                .map(str::trim)
124                .filter(|s| !s.is_empty())
125                .map(|s| canonical(Path::new(s)))
126                .collect::<Vec<_>>()
127        })
128        .collect()
129}
130
131fn trust_all_env() -> bool {
132    matches!(
133        std::env::var(TRUST_ALL_ENV).ok().as_deref(),
134        Some("1" | "true")
135    )
136}
137
138/// Whether `root`'s project-local security-sensitive overrides may be applied,
139/// given the CURRENT content hash of its `.lean-ctx.toml`.
140///
141/// True when the trust-all env is set, the root is in the env root list, or the
142/// store pins this exact `(path, config_hash)` pair. A stored entry whose hash no
143/// longer matches `config_hash` is treated as untrusted — the file changed since
144/// it was reviewed, so re-trust is required.
145#[must_use]
146pub(crate) fn is_trusted_for(root: &Path, config_hash: &str) -> bool {
147    if trust_all_env() {
148        return true;
149    }
150    let canon = canonical(root);
151    if canon.is_empty() {
152        return false;
153    }
154    if env_trusted_roots().contains(&canon) {
155        return true;
156    }
157    load().is_ok_and(|s| {
158        s.workspaces
159            .iter()
160            .any(|w| w.path == canon && w.config_hash == config_hash)
161    })
162}
163
164/// Whether the trust file at `store` pins `(root, config_hash)`. Reads no
165/// environment: the shell-security policy uses it with the trust file in the
166/// real user's config directory (see `config::shell_policy`).
167#[must_use]
168pub(crate) fn is_pinned_in_store(store: &Path, root: &Path, config_hash: &str) -> bool {
169    let canon = canonical(root);
170    if canon.is_empty() {
171        return false;
172    }
173    std::fs::read_to_string(store)
174        .ok()
175        .and_then(|text| toml::from_str::<TrustStore>(&text).ok())
176        .is_some_and(|s| {
177            s.workspaces
178                .iter()
179                .any(|w| w.path == canon && w.config_hash == config_hash)
180        })
181}
182
183/// Whether `root` is trusted at its current `.lean-ctx.toml` content. Reads the
184/// file to compute the hash; prefer `is_trusted_for` when the caller already
185/// holds it (e.g. config load).
186#[must_use]
187pub fn is_trusted(root: &Path) -> bool {
188    is_trusted_for(root, &config_hash_for(root))
189}
190
191/// Trust `root` at its current `.lean-ctx.toml` content. Re-trusting an already
192/// trusted path refreshes its pinned hash (and timestamp). Returns the entry.
193pub fn trust(root: &Path) -> Result<TrustedWorkspace, String> {
194    let canon = canonical(root);
195    if canon.is_empty() {
196        return Err("cannot resolve workspace path".into());
197    }
198    let hash = config_hash_for(root);
199    let mut store = load()?;
200    if let Some(existing) = store.workspaces.iter_mut().find(|w| w.path == canon) {
201        existing.config_hash = hash;
202        existing.added_at = now();
203        let updated = existing.clone();
204        save(&store)?;
205        return Ok(updated);
206    }
207    let entry = TrustedWorkspace {
208        path: canon,
209        config_hash: hash,
210        added_at: now(),
211    };
212    store.workspaces.push(entry.clone());
213    save(&store)?;
214    Ok(entry)
215}
216
217/// Remove `root` from the trust store. Returns `true` when an entry was removed.
218pub fn untrust(root: &Path) -> Result<bool, String> {
219    let canon = canonical(root);
220    let mut store = load()?;
221    let before = store.workspaces.len();
222    store.workspaces.retain(|w| w.path != canon);
223    let removed = store.workspaces.len() != before;
224    if removed {
225        save(&store)?;
226    }
227    Ok(removed)
228}
229
230/// All trusted workspaces from the persisted store (env overrides excluded —
231/// those are provenance-free and shown separately by callers when relevant).
232#[must_use]
233pub fn list() -> Vec<TrustedWorkspace> {
234    load().map(|s| s.workspaces).unwrap_or_default()
235}
236
237/// Actionable, single-paragraph explanation for the MCP tool surfaces (#540):
238/// when the active project's `.lean-ctx.toml` carries SECURITY-sensitive
239/// overrides that are being withheld because the workspace is untrusted, name the
240/// ignored keys and the two ways to make them take effect. `None` when the
241/// workspace is trusted, has no project root, has no local config, or its local
242/// config carries no sensitive overrides.
243///
244/// `Config::merge_local` already logs the identical fact via `tracing::warn`, but
245/// that goes to stderr — invisible over an MCP/stdio transport. So a blocked
246/// command (`shell_allowlist*`) or read (`allow_paths`) otherwise gives the agent
247/// no clue why an edit "did nothing"; this surfaces it inside the error itself.
248#[must_use]
249#[doc(hidden)]
250pub fn untrusted_override_notice() -> Option<String> {
251    let root = crate::core::config::Config::find_project_root()?;
252    untrusted_override_notice_for(Path::new(&root))
253}
254
255/// Root-parameterized core of [`untrusted_override_notice`] (the public wrapper
256/// resolves the active project root; this stays testable with an explicit path).
257fn untrusted_override_notice_for(root: &Path) -> Option<String> {
258    let local = crate::core::config::Config::local_path(&root.to_string_lossy());
259    let toml = std::fs::read_to_string(&local).ok()?;
260    let withheld = crate::core::config::local_sensitive_overrides(&toml);
261    if withheld.is_empty() || is_trusted(root) {
262        return None;
263    }
264    let cfg_path = crate::core::config::Config::path().map_or_else(
265        || "the global config".to_string(),
266        |p| p.display().to_string(),
267    );
268    Some(format!(
269        "This workspace's .lean-ctx.toml sets security-sensitive override(s) [{keys}] that \
270         lean-ctx IGNORES because the workspace is untrusted — the usual reason such an edit \
271         appears to do nothing. To apply them, review the file then run `lean-ctx trust` in \
272         {root}, or move the key(s) into the global config ({cfg_path}), which is never \
273         trust-gated.",
274        keys = withheld.join(", "),
275        root = root.display(),
276    ))
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::core::data_dir::isolated_data_dir;
283
284    #[test]
285    fn untrusted_root_is_not_trusted() {
286        let _iso = isolated_data_dir();
287        let dir = tempfile::tempdir().unwrap();
288        assert!(!is_trusted(dir.path()));
289    }
290
291    #[test]
292    fn trust_then_is_trusted_then_untrust() {
293        let _iso = isolated_data_dir();
294        let dir = tempfile::tempdir().unwrap();
295        assert!(!is_trusted(dir.path()));
296        trust(dir.path()).unwrap();
297        assert!(is_trusted(dir.path()));
298        assert!(untrust(dir.path()).unwrap());
299        assert!(!is_trusted(dir.path()));
300    }
301
302    #[test]
303    fn editing_local_config_after_trust_invalidates_pin() {
304        let _iso = isolated_data_dir();
305        let dir = tempfile::tempdir().unwrap();
306        let local = dir.path().join(".lean-ctx.toml");
307        std::fs::write(&local, "theme = \"a\"\n").unwrap();
308        trust(dir.path()).unwrap();
309        assert!(is_trusted(dir.path()));
310        // Content changes → pinned hash no longer matches → untrusted again.
311        std::fs::write(&local, "theme = \"b\"\n").unwrap();
312        assert!(!is_trusted(dir.path()));
313    }
314
315    #[test]
316    fn env_trust_all_overrides_store() {
317        let _iso = isolated_data_dir();
318        let dir = tempfile::tempdir().unwrap();
319        crate::test_env::set_var(TRUST_ALL_ENV, "1");
320        assert!(is_trusted(dir.path()));
321        crate::test_env::remove_var(TRUST_ALL_ENV);
322        assert!(!is_trusted(dir.path()));
323    }
324
325    #[test]
326    fn env_trusted_roots_lists_canonical_path() {
327        let _iso = isolated_data_dir();
328        let dir = tempfile::tempdir().unwrap();
329        let canon = canonical(dir.path());
330        crate::test_env::set_var(TRUSTED_ROOTS_ENV, &canon);
331        assert!(is_trusted(dir.path()));
332        crate::test_env::remove_var(TRUSTED_ROOTS_ENV);
333    }
334
335    #[test]
336    fn retrust_after_edit_repins_new_hash() {
337        let _iso = isolated_data_dir();
338        let dir = tempfile::tempdir().unwrap();
339        let local = dir.path().join(".lean-ctx.toml");
340        std::fs::write(&local, "theme = \"a\"\n").unwrap();
341        trust(dir.path()).unwrap();
342        std::fs::write(&local, "theme = \"b\"\n").unwrap();
343        assert!(!is_trusted(dir.path()));
344        trust(dir.path()).unwrap();
345        assert!(is_trusted(dir.path()));
346    }
347
348    // #540: the notice is the visible counterpart to the stderr-only merge_local
349    // warning — an untrusted workspace with sensitive overrides must explain the
350    // gate (and the `lean-ctx trust` remedy) right where the tool blocks.
351    #[test]
352    fn untrusted_sensitive_override_yields_actionable_notice() {
353        let _iso = isolated_data_dir();
354        let dir = tempfile::tempdir().unwrap();
355        std::fs::write(
356            dir.path().join(".lean-ctx.toml"),
357            "allow_paths = [\"/srv/data\"]\nshell_allowlist_extra = [\"glab\"]\n",
358        )
359        .unwrap();
360        let notice = untrusted_override_notice_for(dir.path()).expect("untrusted → notice");
361        assert!(notice.contains("allow_paths"), "{notice}");
362        assert!(notice.contains("shell_allowlist_extra"), "{notice}");
363        assert!(notice.contains("lean-ctx trust"), "{notice}");
364    }
365
366    #[test]
367    fn trusted_workspace_yields_no_notice() {
368        let _iso = isolated_data_dir();
369        let dir = tempfile::tempdir().unwrap();
370        std::fs::write(
371            dir.path().join(".lean-ctx.toml"),
372            "allow_paths = [\"/srv/data\"]\n",
373        )
374        .unwrap();
375        trust(dir.path()).unwrap();
376        assert!(untrusted_override_notice_for(dir.path()).is_none());
377    }
378
379    #[test]
380    fn no_local_config_yields_no_notice() {
381        let _iso = isolated_data_dir();
382        let dir = tempfile::tempdir().unwrap();
383        assert!(untrusted_override_notice_for(dir.path()).is_none());
384    }
385
386    #[test]
387    fn comfort_only_override_yields_no_notice() {
388        let _iso = isolated_data_dir();
389        let dir = tempfile::tempdir().unwrap();
390        // Comfort knobs are never gated, so they never trigger the trust notice.
391        std::fs::write(dir.path().join(".lean-ctx.toml"), "theme = \"dark\"\n").unwrap();
392        assert!(untrusted_override_notice_for(dir.path()).is_none());
393    }
394}