Skip to content

Commit 914fed9

Browse files
authored
Merge pull request #16 from penso/secrecy
Wrap secrets in secrecy::Secret<String> to prevent leaks
2 parents 5f5b0b9 + 028192c commit 914fed9

32 files changed

Lines changed: 448 additions & 230 deletions

CLAUDE.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,42 @@ The `auth` subcommand (`crates/cli/src/auth_commands.rs`) provides:
150150
- `moltis auth reset-identity` — clear identity and user profile (triggers
151151
onboarding on next load)
152152

153+
## Sensitive Data Handling
154+
155+
Never use plain `String` for passwords, API keys, tokens, or any secret
156+
material. Use `secrecy::Secret<String>` instead — it redacts `Debug` output,
157+
prevents accidental `Display`, and zeroes memory on drop.
158+
159+
```rust
160+
use secrecy::{ExposeSecret, Secret};
161+
162+
// Store secrets wrapped
163+
struct Config {
164+
api_key: Secret<String>,
165+
}
166+
167+
// Construct: wrap at the boundary
168+
let cfg = Config { api_key: Secret::new(raw_key) };
169+
170+
// Use: expose only at the point of consumption
171+
req.header("Authorization", format!("Bearer {}", cfg.api_key.expose_secret()));
172+
```
173+
174+
Rules:
175+
- **Struct fields** holding secrets must be `Secret<String>` (or
176+
`Option<Secret<String>>`).
177+
- **Function parameters** can stay `&str`; call `.expose_secret()` at the call
178+
site.
179+
- **Serde deserialize** works automatically (secrecy's `serde` feature).
180+
- **Serde serialize** requires a custom helper when round-tripping is needed
181+
(config files, token storage). See `serialize_secret` /
182+
`serialize_option_secret` in `crates/oauth/src/types.rs`.
183+
- **Debug impls**: replace `#[derive(Debug)]` with a manual impl that prints
184+
`[REDACTED]` for secret fields.
185+
- **RwLock guards**: when a `RwLock<Option<Secret<String>>>` read guard is
186+
followed by a write in the same function, scope the read guard in a block
187+
`{ let guard = lock.read().await; ... }` to avoid deadlocks.
188+
153189
## Provider Implementation Guidelines
154190

155191
### Async all the way down

Cargo.lock

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ fd-lock = "4"
7171
genai = "0.5"
7272
open = "5.3"
7373
rand = "0.8"
74+
secrecy = { features = ["serde"], version = "0.8" }
7475
sha2 = "0.10"
7576
teloxide = { features = ["macros"], version = "0.13" }
7677
tokio-util = "0.7"

crates/agents/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ moltis-config = { workspace = true }
2121
moltis-sessions = { workspace = true }
2222
moltis-skills = { workspace = true }
2323
reqwest = { workspace = true }
24+
secrecy = { workspace = true }
2425
serde = { workspace = true }
2526
serde_json = { workspace = true }
2627
tokio = { workspace = true }

crates/agents/src/auth_profiles.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
/// OAuth + API key credential management with token refresh, stored per-agent.
2+
use secrecy::Secret;
3+
24
pub struct AuthProfile {
35
pub provider: String,
46
pub credentials: Credentials,
57
}
68

79
pub enum Credentials {
8-
ApiKey(String),
10+
ApiKey(Secret<String>),
911
OAuth {
10-
access_token: String,
11-
refresh_token: Option<String>,
12+
access_token: Secret<String>,
13+
refresh_token: Option<Secret<String>>,
1214
expires_at: Option<u64>,
1315
},
1416
}

crates/agents/src/providers/anthropic.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
use std::pin::Pin;
22

3-
use {async_trait::async_trait, futures::StreamExt, tokio_stream::Stream};
3+
use {async_trait::async_trait, futures::StreamExt, secrecy::ExposeSecret, tokio_stream::Stream};
44

55
use tracing::{debug, trace, warn};
66

77
use crate::model::{CompletionResponse, LlmProvider, StreamEvent, ToolCall, Usage};
88

99
pub struct AnthropicProvider {
10-
api_key: String,
10+
api_key: secrecy::Secret<String>,
1111
model: String,
1212
base_url: String,
1313
client: reqwest::Client,
@@ -16,7 +16,7 @@ pub struct AnthropicProvider {
1616
impl AnthropicProvider {
1717
pub fn new(api_key: String, model: String, base_url: String) -> Self {
1818
Self {
19-
api_key,
19+
api_key: secrecy::Secret::new(api_key),
2020
model,
2121
base_url,
2222
client: reqwest::Client::new(),
@@ -161,7 +161,7 @@ impl LlmProvider for AnthropicProvider {
161161
let http_resp = self
162162
.client
163163
.post(format!("{}/v1/messages", self.base_url))
164-
.header("x-api-key", &self.api_key)
164+
.header("x-api-key", self.api_key.expose_secret())
165165
.header("anthropic-version", "2023-06-01")
166166
.header("content-type", "application/json")
167167
.json(&body)
@@ -221,7 +221,7 @@ impl LlmProvider for AnthropicProvider {
221221
let resp = match self
222222
.client
223223
.post(format!("{}/v1/messages", self.base_url))
224-
.header("x-api-key", &self.api_key)
224+
.header("x-api-key", self.api_key.expose_secret())
225225
.header("anthropic-version", "2023-06-01")
226226
.header("content-type", "application/json")
227227
.json(&body)

crates/agents/src/providers/github_copilot.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use {
1212
async_trait::async_trait,
1313
futures::StreamExt,
1414
moltis_oauth::{OAuthTokens, TokenStore},
15+
secrecy::{ExposeSecret, Secret},
1516
tokio_stream::Stream,
1617
tracing::{debug, trace, warn},
1718
};
@@ -149,15 +150,18 @@ impl GitHubCopilotProvider {
149150
.unwrap()
150151
.as_secs();
151152
if now + 60 < expires_at {
152-
return Ok(copilot_tokens.access_token);
153+
return Ok(copilot_tokens.access_token.expose_secret().clone());
153154
}
154155
}
155156

156157
// Exchange GitHub token for Copilot API token
157158
let resp = self
158159
.client
159160
.get(COPILOT_TOKEN_URL)
160-
.header("Authorization", format!("token {}", tokens.access_token))
161+
.header(
162+
"Authorization",
163+
format!("token {}", tokens.access_token.expose_secret()),
164+
)
161165
.header("Accept", "application/json")
162166
.header(
163167
"User-Agent",
@@ -175,7 +179,7 @@ impl GitHubCopilotProvider {
175179

176180
// Cache the Copilot API token
177181
let _ = self.token_store.save("github-copilot-api", &OAuthTokens {
178-
access_token: copilot_resp.token.clone(),
182+
access_token: Secret::new(copilot_resp.token.clone()),
179183
refresh_token: None,
180184
expires_at: Some(copilot_resp.expires_at),
181185
});

crates/agents/src/providers/kimi_code.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use {
99
async_trait::async_trait,
1010
futures::StreamExt,
1111
moltis_oauth::{OAuthTokens, TokenStore, kimi_headers},
12+
secrecy::{ExposeSecret, Secret},
1213
tokio_stream::Stream,
1314
tracing::{debug, trace, warn},
1415
};
@@ -59,17 +60,18 @@ impl KimiCodeProvider {
5960
if now + REFRESH_THRESHOLD_SECS >= expires_at {
6061
if let Some(ref refresh_token) = tokens.refresh_token {
6162
debug!("refreshing kimi-code token");
62-
let new_tokens = refresh_access_token(&self.client, refresh_token).await?;
63+
let new_tokens =
64+
refresh_access_token(&self.client, refresh_token.expose_secret()).await?;
6365
self.token_store.save(PROVIDER_NAME, &new_tokens)?;
64-
return Ok(new_tokens.access_token);
66+
return Ok(new_tokens.access_token.expose_secret().clone());
6567
}
6668
return Err(anyhow::anyhow!(
6769
"kimi-code token expired and no refresh token available"
6870
));
6971
}
7072
}
7173

72-
Ok(tokens.access_token)
74+
Ok(tokens.access_token.expose_secret().clone())
7375
}
7476
}
7577

@@ -113,8 +115,8 @@ pub async fn refresh_access_token(
113115
});
114116

115117
Ok(OAuthTokens {
116-
access_token: body.access_token,
117-
refresh_token: body.refresh_token,
118+
access_token: Secret::new(body.access_token),
119+
refresh_token: body.refresh_token.map(Secret::new),
118120
expires_at,
119121
})
120122
}

0 commit comments

Comments
 (0)