Skip to main content

lean_ctx/core/providers/
credentials.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Stored connection values for the built-in context sources.
4//!
5//! Providers read their token and settings from the environment. Agent hosts
6//! do not always pass the user's shell environment to an MCP server (Codex
7//! passes only an allowlist), so a token exported in a shell may never reach
8//! the Engine. This store holds the same values in the data directory
9//! (`credentials/providers.json`, mode 0600 on Unix): an environment variable
10//! that is set always wins, a stored value fills the gap.
11//!
12//! Only the names in [`ALLOWED`] can be stored, values are never printed, and
13//! the store is written atomically.
14//!
15//! `provider check --stdin` tests candidate values before they are stored:
16//! [`set_candidates`] installs them for this process only, and while they are
17//! installed [`var`] answers from them alone, so neither the environment nor
18//! the store can make a wrong candidate look connected.
19
20use std::collections::BTreeMap;
21use std::env::VarError;
22use std::path::PathBuf;
23use std::sync::RwLock;
24
25/// Variables the built-in sources read, and the only names this store keeps.
26pub const ALLOWED: &[&str] = &[
27    "GITHUB_TOKEN",
28    "GITHUB_API_URL",
29    "GITHUB_REPOSITORY",
30    "GITLAB_TOKEN",
31    "GITLAB_HOST",
32    "CI_PROJECT_PATH",
33    "JIRA_URL",
34    "JIRA_EMAIL",
35    "JIRA_TOKEN",
36    "JIRA_PROJECT",
37    "JIRA_DEPLOYMENT",
38    "DATABASE_URL",
39];
40
41/// The settings each checkable source reads from this store.
42const SOURCE_NAMES: &[(&str, &[&str])] = &[
43    (
44        "github",
45        &["GITHUB_TOKEN", "GITHUB_API_URL", "GITHUB_REPOSITORY"],
46    ),
47    (
48        "gitlab",
49        &["GITLAB_TOKEN", "GITLAB_HOST", "CI_PROJECT_PATH"],
50    ),
51    (
52        "jira",
53        &[
54            "JIRA_URL",
55            "JIRA_EMAIL",
56            "JIRA_TOKEN",
57            "JIRA_PROJECT",
58            "JIRA_DEPLOYMENT",
59        ],
60    ),
61    ("postgres", &["DATABASE_URL"]),
62];
63
64/// Variables whose values are secrets, including the aliases providers read.
65pub const SECRET_NAMES: &[&str] = &[
66    "GITHUB_TOKEN",
67    "GH_TOKEN",
68    "LEAN_CTX_GITHUB_TOKEN",
69    "GITLAB_TOKEN",
70    "LEAN_CTX_GITLAB_TOKEN",
71    "CI_JOB_TOKEN",
72    "JIRA_TOKEN",
73    "DATABASE_URL",
74];
75
76const MAX_VALUE: usize = 4096;
77const MAX_NAME: usize = 64;
78/// Upper bound for `NAME=value` input on stdin (every setting of every source fits).
79pub const MAX_INPUT_BYTES: usize = 64 * 1024;
80
81type Store = BTreeMap<String, String>;
82
83static CANDIDATES: RwLock<Option<Store>> = RwLock::new(None);
84
85/// The names `provider` reads from this store; `None` for an unknown source.
86pub fn names_for(provider: &str) -> Option<&'static [&'static str]> {
87    SOURCE_NAMES
88        .iter()
89        .find(|(id, _)| *id == provider)
90        .map(|(_, names)| *names)
91}
92
93/// Refuses a name outside `[A-Z][A-Z0-9_]*` or outside [`ALLOWED`]. The
94/// message quotes the name only when it has the variable shape, so a value
95/// pasted into the name position is never echoed.
96pub fn validate_name(name: &str) -> Result<(), String> {
97    let shaped = !name.is_empty()
98        && name.len() <= MAX_NAME
99        && name.starts_with(|c: char| c.is_ascii_uppercase())
100        && name
101            .chars()
102            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
103    if !shaped {
104        return Err(
105            "a setting name uses only A-Z, 0-9 and _ (for example GITHUB_TOKEN)".to_string(),
106        );
107    }
108    if !ALLOWED.contains(&name) {
109        return Err(format!("{name} is not a source setting this store keeps"));
110    }
111    Ok(())
112}
113
114fn validate_pair(name: &str, value: &str) -> Result<(), String> {
115    validate_name(name)?;
116    if value.trim().is_empty() {
117        return Err(format!("{name} is empty"));
118    }
119    if value.len() > MAX_VALUE || value.contains(['\n', '\r', '\0']) {
120        return Err(format!(
121            "{name} must be one line of at most {MAX_VALUE} bytes"
122        ));
123    }
124    Ok(())
125}
126
127/// Installs candidate values for this process. Every pair is validated and
128/// must be a setting of `provider`; nothing is installed on error.
129pub fn set_candidates(provider: &str, pairs: &[(String, String)]) -> Result<(), String> {
130    let names = names_for(provider).ok_or_else(|| format!("{provider} keeps no settings here"))?;
131    for (name, value) in pairs {
132        validate_pair(name, value)?;
133        if !names.contains(&name.as_str()) {
134            return Err(format!("{name} is not a setting of {provider}"));
135        }
136    }
137    let store = pairs
138        .iter()
139        .map(|(name, value)| (name.clone(), value.trim().to_string()))
140        .collect();
141    *CANDIDATES
142        .write()
143        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(store);
144    Ok(())
145}
146
147/// Removes candidate values installed by [`set_candidates`].
148pub fn clear_candidates() {
149    *CANDIDATES
150        .write()
151        .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
152}
153
154/// Whether candidate values are installed (a `check --stdin` is running).
155pub fn candidates_active() -> bool {
156    CANDIDATES
157        .read()
158        .unwrap_or_else(std::sync::PoisonError::into_inner)
159        .is_some()
160}
161
162fn path() -> Result<PathBuf, String> {
163    Ok(crate::core::paths::data_dir()?
164        .join("credentials")
165        .join("providers.json"))
166}
167
168pub fn load() -> Store {
169    let Ok(path) = path() else {
170        return Store::new();
171    };
172    let Ok(bytes) = std::fs::read(path) else {
173        return Store::new();
174    };
175    let store: Store = serde_json::from_slice(&bytes).unwrap_or_default();
176    store
177        .into_iter()
178        .filter(|(name, _)| ALLOWED.contains(&name.as_str()))
179        .collect()
180}
181
182fn save(store: &Store) -> Result<(), String> {
183    let path = path()?;
184    let parent = path
185        .parent()
186        .ok_or_else(|| "credential store has no parent folder".to_string())?;
187    std::fs::create_dir_all(parent)
188        .map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
189    let json = serde_json::to_vec_pretty(store).map_err(|e| format!("serialize error: {e}"))?;
190    let tmp = path.with_extension("json.tmp");
191    write_private(&tmp, &json)?;
192    std::fs::rename(&tmp, &path).map_err(|e| format!("cannot persist credentials: {e}"))
193}
194
195#[cfg(unix)]
196fn write_private(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
197    use std::io::Write;
198    use std::os::unix::fs::OpenOptionsExt;
199    let mut file = std::fs::OpenOptions::new()
200        .write(true)
201        .create(true)
202        .truncate(true)
203        .mode(0o600)
204        .open(path)
205        .map_err(|e| format!("cannot open {}: {e}", path.display()))?;
206    file.write_all(bytes)
207        .map_err(|e| format!("cannot write {}: {e}", path.display()))
208}
209
210#[cfg(not(unix))]
211fn write_private(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
212    std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))
213}
214
215/// `name` from the environment, else from the store. Drop-in for
216/// [`std::env::var`]: a variable that is set, even to an empty value, wins.
217pub fn var(name: &str) -> Result<String, VarError> {
218    if let Some(candidates) = CANDIDATES
219        .read()
220        .unwrap_or_else(std::sync::PoisonError::into_inner)
221        .as_ref()
222    {
223        return candidates.get(name).cloned().ok_or(VarError::NotPresent);
224    }
225    match std::env::var(name) {
226        Ok(value) => Ok(value),
227        Err(missing) => load().remove(name).ok_or(missing),
228    }
229}
230
231/// Whether `name` resolves from the environment or the store.
232pub fn is_set(name: &str) -> bool {
233    var(name).is_ok()
234}
235
236/// Names held in the store, sorted. Values are never returned.
237pub fn stored_names() -> Vec<String> {
238    load().into_keys().collect()
239}
240
241/// Stores `pairs`, replacing earlier values of the same names. Every pair is
242/// checked before anything is written.
243pub fn set(pairs: &[(String, String)]) -> Result<Vec<String>, String> {
244    for (name, value) in pairs {
245        validate_pair(name, value)?;
246    }
247    let mut store = load();
248    for (name, value) in pairs {
249        store.insert(name.clone(), value.trim().to_string());
250    }
251    save(&store)?;
252    Ok(pairs.iter().map(|(name, _)| name.clone()).collect())
253}
254
255/// Removes `names` from the store; returns how many were present.
256pub fn remove(names: &[String]) -> Result<usize, String> {
257    for name in names {
258        validate_name(name)?;
259    }
260    let mut store = load();
261    let before = store.len();
262    store.retain(|name, _| !names.contains(name));
263    let removed = before - store.len();
264    if removed > 0 {
265        save(&store)?;
266    }
267    Ok(removed)
268}
269
270/// Parses `NAME=value` lines (blank lines and `#` comments skipped).
271pub fn parse_pairs(input: &str) -> Result<Vec<(String, String)>, String> {
272    input
273        .lines()
274        .map(str::trim)
275        .filter(|line| !line.is_empty() && !line.starts_with('#'))
276        .map(|line| {
277            line.split_once('=')
278                .map(|(name, value)| (name.trim().to_string(), value.to_string()))
279                .ok_or_else(|| "each line must be NAME=value".to_string())
280        })
281        .collect()
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn with_store<T>(run: impl FnOnce() -> T) -> T {
289        let _lock = crate::core::data_dir::test_env_lock();
290        let data = tempfile::tempdir().unwrap();
291        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
292        crate::test_env::remove_var("JIRA_PROJECT");
293        crate::test_env::remove_var("GITLAB_HOST");
294        let result = run();
295        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
296        result
297    }
298
299    #[test]
300    fn stored_values_fill_the_environment_gap() {
301        with_store(|| {
302            assert!(var("JIRA_PROJECT").is_err());
303            set(&[("JIRA_PROJECT".into(), " LEAN".into())]).unwrap();
304            assert_eq!(var("JIRA_PROJECT").unwrap(), "LEAN");
305            crate::test_env::set_var("JIRA_PROJECT", "ENV");
306            assert_eq!(var("JIRA_PROJECT").unwrap(), "ENV");
307            crate::test_env::remove_var("JIRA_PROJECT");
308            assert_eq!(stored_names(), vec!["JIRA_PROJECT".to_string()]);
309            assert_eq!(remove(&["JIRA_PROJECT".into()]).unwrap(), 1);
310            assert!(var("JIRA_PROJECT").is_err());
311            // Installed candidates are the only answer: neither the store
312            // nor the environment can make a wrong candidate look connected.
313            set(&[("JIRA_PROJECT".into(), "STORED".into())]).unwrap();
314            crate::test_env::set_var("JIRA_TOKEN", "env-token");
315            set_candidates("jira", &[("JIRA_URL".into(), "https://c.example".into())]).unwrap();
316            let seen = (var("JIRA_URL"), var("JIRA_PROJECT"), var("JIRA_TOKEN"));
317            clear_candidates();
318            crate::test_env::remove_var("JIRA_TOKEN");
319            assert_eq!(seen.0.unwrap(), "https://c.example");
320            assert!(seen.1.is_err() && seen.2.is_err());
321            assert_eq!(var("JIRA_PROJECT").unwrap(), "STORED");
322        });
323    }
324
325    #[test]
326    fn only_known_single_line_values_are_stored() {
327        with_store(|| {
328            assert!(set(&[("PATH".into(), "/bin".into())]).is_err());
329            // A malformed name is refused without echoing it: it may be a
330            // token pasted into the wrong position.
331            for bad in [
332                concat!("gh", "p_SECRETVALUE"),
333                "GITHUB TOKEN",
334                "../GITHUB_TOKEN",
335                "",
336            ] {
337                let error = set(&[(bad.into(), "x".into())]).unwrap_err();
338                assert!(bad.is_empty() || !error.contains(bad), "{error}");
339                assert!(remove(&[bad.to_string()]).is_err());
340            }
341            // Candidates must belong to the source being checked.
342            assert!(set_candidates("github", &[("JIRA_TOKEN".into(), "t".into())]).is_err());
343            assert!(!candidates_active());
344            assert!(set(&[("GITLAB_HOST".into(), "a\nb".into())]).is_err());
345            assert!(set(&[("GITLAB_HOST".into(), "   ".into())]).is_err());
346            // A rejected pair leaves earlier pairs of the same call unwritten.
347            assert!(
348                set(&[
349                    ("GITLAB_HOST".into(), "gitlab.example".into()),
350                    ("HOME".into(), "/x".into())
351                ])
352                .is_err()
353            );
354            assert!(stored_names().is_empty());
355        });
356    }
357
358    #[cfg(unix)]
359    #[test]
360    fn the_store_is_private_to_the_user() {
361        use std::os::unix::fs::PermissionsExt;
362        with_store(|| {
363            set(&[("GITLAB_HOST".into(), "gitlab.example".into())]).unwrap();
364            let mode = std::fs::metadata(path().unwrap())
365                .unwrap()
366                .permissions()
367                .mode();
368            assert_eq!(mode & 0o777, 0o600);
369        });
370    }
371
372    #[test]
373    fn pairs_parse_from_lines() {
374        let pairs =
375            parse_pairs("# token\nGITLAB_HOST=git.example\n\nJIRA_URL=https://x=y\n").unwrap();
376        assert_eq!(
377            pairs,
378            vec![
379                ("GITLAB_HOST".to_string(), "git.example".to_string()),
380                ("JIRA_URL".to_string(), "https://x=y".to_string())
381            ]
382        );
383        assert!(parse_pairs("no equals").is_err());
384    }
385}