Skip to main content

agentd/sec/
secret.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The secret front door — interpolation tokens that let a config name a
3//! credential without ever containing one.
4//!
5//! Secrets come from the environment, a mounted file, or an interactive
6//! startup prompt: **never** from the config file and **never** into a log
7//! line. This module reads a credential from a mounted file (a Kubernetes
8//! `Secret` volume), trims the trailing newline kubelet leaves on a projected
9//! file, and resolves the two interpolation tokens a declared header value may
10//! carry:
11//!
12//! - `{{secret:NAME}}` — the value entered at startup for `NAME` under
13//!   `--prompt-missing` if there is one, else the process env var `NAME`.
14//! - `{{secret-file:PATH}}` — the contents of the mounted file at `PATH`,
15//!   re-read at the moment of use, so rotating the mounted file takes effect
16//!   without restarting the daemon.
17//!
18//! Both resolve locally — a filesystem path or an env var, never a URL and
19//! never a network fetch — so resolving a secret can never itself become an
20//! egress channel. The **template** is structural and may live in the config
21//! file or on a flag; the **resolved value** is materialized only at the
22//! instant of use, is never retained, and never reaches a log or error line.
23//! That split is what makes "the config file is secret-free" a fact rather
24//! than a convention: what the file holds is a reference, not a value.
25
26/// Read a credential from a mounted file, trimming a single trailing newline
27/// (kubelet projects a Secret value verbatim; an editor or `echo` commonly
28/// appends a `\n`). Errors carry the path but never the contents, so a failed
29/// read cannot spill the credential into a log or an error line.
30pub fn read_token_file(path: &str) -> Result<String, String> {
31    let raw = std::fs::read_to_string(path)
32        .map_err(|e| format!("cannot read secret file {path}: {e}"))?;
33    Ok(trim_token(&raw).to_string())
34}
35
36/// Trim a trailing `\n` (and a `\r\n`) from a file-read token. Only the final
37/// line-ending is stripped — interior whitespace is part of the credential.
38fn trim_token(s: &str) -> &str {
39    s.strip_suffix('\n')
40        .map(|t| t.strip_suffix('\r').unwrap_or(t))
41        .unwrap_or(s)
42}
43
44/// Values entered interactively at startup (`--prompt-missing`).
45///
46/// They live HERE — in process memory, consulted before the environment — and
47/// nowhere else: never written back to a config file (that is how secrets end
48/// up in git) and never exported into the environment (children would inherit
49/// them). A daemon restart re-prompts, which is the honest cost of not
50/// persisting a credential anywhere.
51static PROMPTED: std::sync::Mutex<Option<std::collections::BTreeMap<String, String>>> =
52    std::sync::Mutex::new(None);
53
54/// Record an interactively-entered value for `{{secret:NAME}}` resolution.
55pub fn set_prompted(name: &str, value: String) {
56    let mut g = PROMPTED.lock().unwrap_or_else(|e| e.into_inner());
57    g.get_or_insert_with(Default::default)
58        .insert(name.to_string(), value);
59}
60
61/// Whether `{{secret:NAME}}` would resolve right now (prompted or environment).
62pub fn secret_available(name: &str) -> bool {
63    prompted_of(name).is_some() || std::env::var(name).is_ok()
64}
65
66pub fn prompted_of(name: &str) -> Option<String> {
67    PROMPTED
68        .lock()
69        .unwrap_or_else(|e| e.into_inner())
70        .as_ref()
71        .and_then(|m| m.get(name).cloned())
72}
73
74/// Resolve every `{{secret:NAME}}` / `{{secret-file:PATH}}` token in `template`
75/// against `env` (the process environment) and the local filesystem, returning
76/// the materialized string. Plain text passes through unchanged. A bad token
77/// (missing env var, unreadable file, or an unterminated `{{`) is an `Err`
78/// with a message that names the ref but never the resolved value.
79///
80/// This is the runtime resolver — it is called at the moment of use, so a
81/// rotated `{{secret-file:…}}` is picked up on the next call. `--validate-config`
82/// and startup call [`refs_resolvable`] for the side-effect-free pre-flight.
83pub fn resolve(template: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
84    if !template.contains("{{") {
85        return Ok(template.to_string());
86    }
87    let mut out = String::with_capacity(template.len());
88    let mut rest = template;
89    while let Some(open) = rest.find("{{") {
90        out.push_str(&rest[..open]);
91        let after = &rest[open + 2..];
92        let close = after
93            .find("}}")
94            .ok_or_else(|| "unterminated secret ref '{{' (want '{{secret:NAME}}')".to_string())?;
95        let token = after[..close].trim();
96        out.push_str(&resolve_one(token, env)?);
97        rest = &after[close + 2..];
98    }
99    out.push_str(rest);
100    Ok(out)
101}
102
103/// Resolve a single `secret:NAME` / `secret-file:PATH` token body (without the
104/// surrounding braces). Any other token is an error — a literal `{{…}}` that is
105/// not a secret ref is rejected rather than silently passed through, so a typo
106/// can't smuggle braces onto the wire.
107fn resolve_one(token: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
108    if let Some(path) = token.strip_prefix("secret-file:") {
109        let path = path.trim();
110        if path.is_empty() {
111            return Err("empty {{secret-file:}} path".to_string());
112        }
113        read_token_file(path)
114    } else if let Some(name) = token.strip_prefix("secret:") {
115        let name = name.trim();
116        if name.is_empty() {
117            return Err("empty {{secret:}} name".to_string());
118        }
119        prompted_of(name)
120            .or_else(|| env(name))
121            .ok_or_else(|| format!("{{{{secret:{name}}}}} is not set in the environment"))
122    } else {
123        Err(format!(
124            "unknown interpolation token '{{{{{token}}}}}' (want {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}})"
125        ))
126    }
127}
128
129/// Does `value` contain at least one `{{secret:…}}` / `{{secret-file:…}}` ref?
130/// The validator uses this to tell a legal secret *reference* apart from an
131/// illegal inline literal under a secret-shaped key: a header named
132/// `Authorization` holding a reference is fine, the same header holding the
133/// token itself is a config error.
134pub fn has_secret_ref(value: &str) -> bool {
135    value.contains("{{secret:") || value.contains("{{secret-file:")
136}
137
138/// Side-effect-free-as-possible pre-flight for `--validate-config` / startup:
139/// every ref in `template` must resolve (the env var is set; the file exists
140/// and is readable). Returns the same diagnostics `resolve` would, without
141/// retaining the resolved bytes. A `{{secret-file:…}}` IS read here — an
142/// unreadable secret file must fail the config check rather than surface as a
143/// mystery 401 on the first outbound request — but the contents are dropped
144/// immediately.
145pub fn refs_resolvable(template: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<(), String> {
146    resolve(template, env).map(|_| ())
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::io::Write;
153
154    fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
155        move |k: &str| {
156            pairs
157                .iter()
158                .find(|(n, _)| *n == k)
159                .map(|(_, v)| (*v).to_string())
160        }
161    }
162
163    #[test]
164    fn trims_one_trailing_newline_only() {
165        assert_eq!(trim_token("tok\n"), "tok");
166        assert_eq!(trim_token("tok\r\n"), "tok");
167        assert_eq!(trim_token("tok"), "tok");
168        // interior + a blank trailing line: only the final \n goes.
169        assert_eq!(trim_token("a b\n\n"), "a b\n");
170        // no over-trim of interior whitespace.
171        assert_eq!(trim_token("  tok  \n"), "  tok  ");
172    }
173
174    #[test]
175    fn read_token_file_reads_and_trims() {
176        let mut f = tempfile::NamedTempFile::new().unwrap();
177        writeln!(f, "super-secret").unwrap();
178        let v = read_token_file(f.path().to_str().unwrap()).unwrap();
179        assert_eq!(v, "super-secret");
180    }
181
182    #[test]
183    fn read_token_file_missing_is_error_without_contents() {
184        let e = read_token_file("/no/such/secret/file").unwrap_err();
185        assert!(e.contains("cannot read secret file"));
186    }
187
188    #[test]
189    fn resolve_passthrough_and_env_ref() {
190        let env = env_of(&[("ANTHROPIC_API_KEY", "k-123")]);
191        assert_eq!(resolve("plain text", &env).unwrap(), "plain text");
192        assert_eq!(
193            resolve("x-api-key: {{secret:ANTHROPIC_API_KEY}}", &env).unwrap(),
194            "x-api-key: k-123"
195        );
196        // a bare token with surrounding text on both sides.
197        assert_eq!(
198            resolve("Bearer {{secret:ANTHROPIC_API_KEY}}!", &env).unwrap(),
199            "Bearer k-123!"
200        );
201    }
202
203    #[test]
204    fn resolve_file_ref_reads_fresh_and_trims() {
205        let mut f = tempfile::NamedTempFile::new().unwrap();
206        writeln!(f, "file-tok").unwrap();
207        let path = f.path().to_str().unwrap();
208        let env = env_of(&[]);
209        let tmpl = format!("Bearer {{{{secret-file:{path}}}}}");
210        assert_eq!(resolve(&tmpl, &env).unwrap(), "Bearer file-tok");
211    }
212
213    #[test]
214    fn resolve_missing_env_is_error_and_does_not_leak_value() {
215        let env = env_of(&[]);
216        let e = resolve("{{secret:NOPE}}", &env).unwrap_err();
217        assert!(e.contains("NOPE"));
218        assert!(e.contains("not set"));
219    }
220
221    #[test]
222    fn resolve_unknown_token_and_unterminated_are_errors() {
223        let env = env_of(&[]);
224        assert!(resolve("{{bogus:x}}", &env).is_err());
225        assert!(resolve("{{secret:", &env).is_err());
226        assert!(resolve("{{secret:}}", &env).is_err());
227        assert!(resolve("{{secret-file:}}", &env).is_err());
228    }
229
230    #[test]
231    fn has_secret_ref_detects_both_kinds() {
232        assert!(has_secret_ref("{{secret:X}}"));
233        assert!(has_secret_ref("Bearer {{secret-file:/p}}"));
234        assert!(!has_secret_ref("plain value"));
235        assert!(!has_secret_ref("2023-06-01"));
236    }
237}