Skip to main content

agora_agentkit/
secrets.rs

1//! Secret value management with zeroization and redacted output.
2//!
3//! Use [`Secret::from_file`] to load secrets from file-mounted paths
4//! (e.g., Docker secrets). The inner value is zeroized on drop and never
5//! appears in `Debug` or `Display` output.
6
7use std::path::Path;
8use zeroize::Zeroizing;
9
10/// A secret value that is zeroized on drop and redacted in debug/display output.
11///
12/// Use `from_file` to load secrets from file-mounted paths (e.g., Docker secrets).
13/// Use `expose` to access the inner value when needed.
14pub struct Secret(Zeroizing<String>);
15
16impl Secret {
17    /// Load a secret from a file, trimming trailing whitespace.
18    pub fn from_file(path: &Path) -> Result<Self, std::io::Error> {
19        let contents = std::fs::read_to_string(path)?;
20        Ok(Self(Zeroizing::new(contents.trim().to_owned())))
21    }
22
23    /// Create a secret from a string value.
24    pub fn new(value: String) -> Self {
25        Self(Zeroizing::new(value))
26    }
27
28    /// Access the secret value. Use sparingly.
29    pub fn expose(&self) -> &str {
30        &self.0
31    }
32}
33
34impl Clone for Secret {
35    fn clone(&self) -> Self {
36        Self(self.0.clone())
37    }
38}
39
40impl std::fmt::Debug for Secret {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str("[REDACTED]")
43    }
44}
45
46impl std::fmt::Display for Secret {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.write_str("[REDACTED]")
49    }
50}
51
52/// The model the live (`#[ignore]`d) tests run on, at
53/// [`LIVE_TEST_EFFORT`] (Steward, 2026-10-19). Its wire id, since the
54/// pinned misanthropic (1.0.0-alpha.23) has no `Id` for it. Haiku 5.5's
55/// minimum cacheable prefix is 512 tokens (Haiku 4.5's was 4096), and its
56/// thinking is on by default and counts toward `max_tokens`.
57#[cfg(all(test, feature = "client", feature = "misanthropic"))]
58pub(crate) fn live_test_model() -> misanthropic::model::Model {
59    misanthropic::model::Model::Custom("claude-haiku-5-5".into())
60}
61
62/// Low effort: thinking stays adaptive (the default on Haiku 5.5) but
63/// shallow, so the live tests cost little and still exercise the path
64/// production runs.
65#[cfg(all(test, feature = "client", feature = "misanthropic"))]
66pub(crate) const LIVE_TEST_EFFORT: misanthropic::prompt::Effort =
67    misanthropic::prompt::Effort::Low;
68
69/// The Anthropic API key for the live (`#[ignore]`d) tests, from its file
70/// only: `$AGORA_SECRETS_DIR/anthropic_api_key`, default
71/// `$HOME/Projects/agora/secrets/anthropic_api_key`.
72///
73/// Never `ANTHROPIC_API_KEY` from the environment, not even in a test
74/// (Steward, 2026-10-19): the environment leaks to every child process and
75/// through `/proc/<pid>/environ`, and it is the first place malware looks
76/// for that name. `AGORA_SECRETS_DIR` and `HOME` are paths, not secrets.
77#[cfg(all(test, feature = "client", feature = "misanthropic"))]
78pub(crate) fn live_test_api_key() -> Secret {
79    let dir = std::env::var_os("AGORA_SECRETS_DIR")
80        .map(std::path::PathBuf::from)
81        .unwrap_or_else(|| {
82            std::path::PathBuf::from(
83                std::env::var_os("HOME").expect("HOME is set"),
84            )
85            .join("Projects/agora/secrets")
86        });
87    let path = dir.join("anthropic_api_key");
88    Secret::from_file(&path).unwrap_or_else(|e| {
89        panic!(
90            "cannot read the API key from {}: {e} (set AGORA_SECRETS_DIR)",
91            path.display()
92        )
93    })
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use std::io::Write;
100
101    #[test]
102    fn from_file_and_expose() {
103        let mut file = tempfile::NamedTempFile::new().unwrap();
104        writeln!(file, "  my-secret-value  ").unwrap();
105
106        let secret = Secret::from_file(file.path()).unwrap();
107        assert_eq!(secret.expose(), "my-secret-value");
108    }
109
110    #[test]
111    fn debug_is_redacted() {
112        let secret = Secret::new("hunter2".to_string());
113        assert_eq!(format!("{:?}", secret), "[REDACTED]");
114    }
115
116    #[test]
117    fn display_is_redacted() {
118        let secret = Secret::new("hunter2".to_string());
119        assert_eq!(format!("{}", secret), "[REDACTED]");
120    }
121
122    #[test]
123    fn nonexistent_file_returns_error() {
124        let result = Secret::from_file(Path::new("/nonexistent/path"));
125        assert!(result.is_err());
126    }
127
128    #[test]
129    fn clone_preserves_value() {
130        let secret = Secret::new("cloneable".to_string());
131        let cloned = secret.clone();
132        assert_eq!(cloned.expose(), "cloneable");
133    }
134}