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}