Skip to main content

rustyclaw_core/secrets/
mod.rs

1//! Secrets manager backed by an encrypted SecureStore vault.
2//!
3//! The vault is stored at `{credentials_dir}/secrets.json`.  Encryption uses
4//! either a CSPRNG-generated key file (`{credentials_dir}/secrets.key`) or a
5//! user-supplied password — never both.
6//!
7//! ## Storage layout
8//!
9//! | Key pattern            | Content                                          |
10//! |------------------------|--------------------------------------------------|
11//! | `cred:<name>`          | JSON-serialized [`SecretEntry`] metadata          |
12//! | `val:<name>`           | Primary secret value (or private key PEM / note)  |
13//! | `val:<name>:user`      | Username (for `UsernamePassword` kind)             |
14//! | `val:<name>:pub`       | Public key string (for `SshKey` kind)              |
15//! | `val:<name>:fields`    | JSON map of form-field key/value pairs             |
16//! | `val:<name>:card`      | JSON `{cardholder,number,expiry,cvv}`              |
17//! | `val:<name>:card_extra`| JSON map of additional payment card fields         |
18//! | `<bare key>`           | Legacy / raw secrets (API keys, TOTP, etc.)        |
19
20mod types;
21mod vault;
22
23use std::path::PathBuf;
24
25pub use types::{
26    AccessContext, AccessPolicy, BrowserStore, Cookie, CredentialValue, Secret, SecretEntry,
27    SecretKind, WebStorage,
28};
29
30/// Secrets manager backed by an encrypted SecureStore vault.
31pub struct SecretsManager {
32    /// Path to the vault JSON file
33    pub(crate) vault_path: PathBuf,
34    /// Path to the key file (only used when no password is set)
35    pub(crate) key_path: PathBuf,
36    /// Optional user-supplied password (used instead of the key file)
37    pub(crate) password: Option<String>,
38    /// In-memory vault handle (loaded lazily)
39    pub(crate) vault: Option<securestore::SecretsManager>,
40    /// Whether the agent can access secrets without prompting
41    pub(crate) agent_access_enabled: bool,
42}
43
44impl SecretsManager {
45    /// Create a new `SecretsManager` rooted in `credentials_dir`.
46    ///
47    /// The vault and key files are created on-demand the first time a
48    /// mutating operation is performed.
49    pub fn new(credentials_dir: impl Into<PathBuf>) -> Self {
50        let dir: PathBuf = credentials_dir.into();
51        Self {
52            vault_path: dir.join("secrets.json"),
53            key_path: dir.join("secrets.key"),
54            password: None,
55            vault: None,
56            agent_access_enabled: false,
57        }
58    }
59
60    /// Create a `SecretsManager` that uses a password for encryption
61    /// instead of a key file.
62    pub fn with_password(credentials_dir: impl Into<PathBuf>, password: String) -> Self {
63        let dir: PathBuf = credentials_dir.into();
64        Self {
65            vault_path: dir.join("secrets.json"),
66            key_path: dir.join("secrets.key"),
67            password: Some(password),
68            vault: None,
69            agent_access_enabled: false,
70        }
71    }
72
73    /// Set the password after construction (e.g. after prompting the user).
74    ///
75    /// **Note:** This only affects how the vault is opened on next access.
76    /// If the vault already exists on disk with a different key source, you
77    /// must call [`change_password`](Self::change_password) instead.
78    pub fn set_password(&mut self, password: String) {
79        self.password = Some(password);
80        // Invalidate any previously loaded vault so it reloads with the
81        // new key source.
82        self.vault = None;
83    }
84
85    /// Remove the password and invalidate the loaded vault, returning the
86    /// manager to a locked state.
87    pub fn clear_password(&mut self) {
88        self.password = None;
89        self.vault = None;
90    }
91
92    /// Create a `SecretsManager` in a locked state.
93    ///
94    /// The vault file path is known but no password or key file has been
95    /// provided yet.  The vault cannot be accessed until
96    /// [`set_password`](Self::set_password) is called.
97    pub fn locked(credentials_dir: impl Into<PathBuf>) -> Self {
98        let dir: PathBuf = credentials_dir.into();
99        Self {
100            vault_path: dir.join("secrets.json"),
101            key_path: dir.join("secrets.key"),
102            password: None,
103            vault: None,
104            agent_access_enabled: false,
105        }
106    }
107
108    /// Check whether the vault is in a locked state (password-protected
109    /// vault with no password provided yet).
110    ///
111    /// Returns `true` if the vault file exists on disk, no key file is
112    /// present, and no password has been set — meaning the vault cannot
113    /// be decrypted without a password.
114    pub fn is_locked(&self) -> bool {
115        self.vault.is_none()
116            && self.password.is_none()
117            && !self.key_path.exists()
118            && self.vault_path.exists()
119    }
120
121    /// Return the current password, if one has been set.
122    ///
123    /// Used by the TUI to forward the vault password to the gateway
124    /// daemon so it can open the vault without prompting.
125    pub fn password(&self) -> Option<&str> {
126        self.password.as_deref()
127    }
128
129    // ── Access control ──────────────────────────────────────────────
130
131    /// Enable or disable automatic agent access to secrets
132    pub fn set_agent_access(&mut self, enabled: bool) {
133        self.agent_access_enabled = enabled;
134    }
135
136    /// Check if agent has access to secrets
137    pub fn has_agent_access(&self) -> bool {
138        self.agent_access_enabled
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::path::Path;
146    use std::sync::atomic::{AtomicU32, Ordering};
147    use totp_rs::{Algorithm, Secret as TotpSecret, TOTP};
148
149    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
150
151    fn temp_dir() -> PathBuf {
152        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
153        let dir = std::env::temp_dir().join(format!("rustyclaw_test_{}_{}", std::process::id(), id));
154        let _ = std::fs::remove_dir_all(&dir);
155        std::fs::create_dir_all(&dir).unwrap();
156        dir
157    }
158
159    #[test]
160    fn test_secrets_manager_creation() {
161        let dir = temp_dir();
162        let manager = SecretsManager::new(&dir);
163        assert!(!manager.has_agent_access());
164
165        // Vault files should not exist yet (lazy creation)
166        assert!(!dir.join("secrets.json").exists());
167        let _ = std::fs::remove_dir_all(&dir);
168    }
169
170    #[test]
171    fn test_agent_access_control() {
172        let dir = temp_dir();
173        let mut manager = SecretsManager::new(&dir);
174        assert!(!manager.has_agent_access());
175
176        manager.set_agent_access(true);
177        assert!(manager.has_agent_access());
178
179        manager.set_agent_access(false);
180        assert!(!manager.has_agent_access());
181        let _ = std::fs::remove_dir_all(&dir);
182    }
183
184    #[test]
185    fn test_store_and_retrieve() {
186        let dir = temp_dir();
187        let mut manager = SecretsManager::new(&dir);
188        manager.set_agent_access(true);
189
190        manager.store_secret("api_key", "hunter2").unwrap();
191        assert!(Path::new(&dir.join("secrets.json")).exists());
192        assert!(Path::new(&dir.join("secrets.key")).exists());
193
194        let val = manager.get_secret("api_key", false).unwrap();
195        assert_eq!(val, Some("hunter2".to_string()));
196
197        // Non-existent key
198        let missing = manager.get_secret("nope", true).unwrap();
199        assert_eq!(missing, None);
200        let _ = std::fs::remove_dir_all(&dir);
201    }
202
203    #[test]
204    fn test_list_and_delete() {
205        let dir = temp_dir();
206        let mut manager = SecretsManager::new(&dir);
207        manager.set_agent_access(true);
208
209        manager.store_secret("a", "1").unwrap();
210        manager.store_secret("b", "2").unwrap();
211
212        let mut keys = manager.list_secrets();
213        keys.sort();
214        assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
215
216        manager.delete_secret("a").unwrap();
217        let keys = manager.list_secrets();
218        assert_eq!(keys, vec!["b".to_string()]);
219        let _ = std::fs::remove_dir_all(&dir);
220    }
221
222    #[test]
223    fn test_access_denied_without_approval() {
224        let dir = temp_dir();
225        let mut manager = SecretsManager::new(&dir);
226        manager.store_secret("secret", "value").unwrap();
227
228        // Agent access off + no user approval → None
229        let val = manager.get_secret("secret", false).unwrap();
230        assert_eq!(val, None);
231
232        // With user approval → Some
233        let val = manager.get_secret("secret", true).unwrap();
234        assert_eq!(val, Some("value".to_string()));
235        let _ = std::fs::remove_dir_all(&dir);
236    }
237
238    #[test]
239    fn test_reload_from_disk() {
240        let dir = temp_dir();
241
242        // Create and populate
243        {
244            let mut m = SecretsManager::new(&dir);
245            m.store_secret("persist", "yes").unwrap();
246        }
247
248        // Load fresh and read back
249        {
250            let mut m = SecretsManager::new(&dir);
251            m.set_agent_access(true);
252            let val = m.get_secret("persist", false).unwrap();
253            assert_eq!(val, Some("yes".to_string()));
254        }
255        let _ = std::fs::remove_dir_all(&dir);
256    }
257
258    #[test]
259    fn test_password_based_vault() {
260        let dir = temp_dir();
261
262        // Create a password-protected vault and store a secret.
263        {
264            let mut m = SecretsManager::with_password(&dir, "s3cret".to_string());
265            m.store_secret("token", "abc123").unwrap();
266        }
267
268        // Vault file should exist, but key file should NOT.
269        assert!(dir.join("secrets.json").exists());
270        assert!(!dir.join("secrets.key").exists());
271
272        // Reload with the correct password.
273        {
274            let mut m = SecretsManager::with_password(&dir, "s3cret".to_string());
275            m.set_agent_access(true);
276            let val = m.get_secret("token", false).unwrap();
277            assert_eq!(val, Some("abc123".to_string()));
278        }
279
280        // Wrong password should fail to load.
281        {
282            let mut m = SecretsManager::with_password(&dir, "wrong".to_string());
283            assert!(m.get_secret("token", true).is_err());
284        }
285
286        let _ = std::fs::remove_dir_all(&dir);
287    }
288
289    #[test]
290    fn test_change_password() {
291        let dir = temp_dir();
292
293        // Create a key-file vault and store some secrets.
294        {
295            let mut m = SecretsManager::new(&dir);
296            m.store_secret("api_key", "sk-abc").unwrap();
297            m.store_secret("token", "tok-xyz").unwrap();
298        }
299        assert!(dir.join("secrets.json").exists());
300        assert!(dir.join("secrets.key").exists());
301
302        // Re-open with key-file and change to a password.
303        {
304            let mut m = SecretsManager::new(&dir);
305            m.change_password("newpass".to_string()).unwrap();
306        }
307
308        // Key file should be removed after password migration.
309        assert!(!dir.join("secrets.key").exists());
310
311        // Reload with the new password — secrets should still be there.
312        {
313            let mut m = SecretsManager::with_password(&dir, "newpass".to_string());
314            m.set_agent_access(true);
315            assert_eq!(
316                m.get_secret("api_key", false).unwrap(),
317                Some("sk-abc".to_string())
318            );
319            assert_eq!(
320                m.get_secret("token", false).unwrap(),
321                Some("tok-xyz".to_string())
322            );
323        }
324
325        // Old key file should no longer work (it's deleted).
326        // Wrong password should fail.
327        {
328            let mut m = SecretsManager::with_password(&dir, "wrong".to_string());
329            assert!(m.get_secret("api_key", true).is_err());
330        }
331
332        let _ = std::fs::remove_dir_all(&dir);
333    }
334
335    #[test]
336    fn test_change_password_between_passwords() {
337        let dir = temp_dir();
338
339        // Create a password-protected vault.
340        {
341            let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
342            m.store_secret("secret", "value123").unwrap();
343        }
344
345        // Change the password.
346        {
347            let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
348            m.change_password("new_pw".to_string()).unwrap();
349        }
350
351        // New password should work.
352        {
353            let mut m = SecretsManager::with_password(&dir, "new_pw".to_string());
354            m.set_agent_access(true);
355            assert_eq!(
356                m.get_secret("secret", false).unwrap(),
357                Some("value123".to_string())
358            );
359        }
360
361        // Old password should fail.
362        {
363            let mut m = SecretsManager::with_password(&dir, "old_pw".to_string());
364            assert!(m.get_secret("secret", true).is_err());
365        }
366
367        let _ = std::fs::remove_dir_all(&dir);
368    }
369
370    #[test]
371    fn test_totp_setup_and_verify() {
372        let dir = temp_dir();
373        let mut manager = SecretsManager::new(&dir);
374        manager.set_agent_access(true);
375
376        // No TOTP secret initially.
377        assert!(!manager.has_totp());
378
379        // Set up TOTP and get the otpauth:// URL.
380        let url = manager.setup_totp("testuser").unwrap();
381        assert!(url.starts_with("otpauth://totp/"));
382        assert!(url.contains("RustyClaw"));
383        assert!(manager.has_totp());
384
385        // Generate a valid code from the stored secret and verify it.
386        let encoded = manager
387            .get_secret(SecretsManager::TOTP_SECRET_KEY, true)
388            .unwrap()
389            .unwrap();
390        let secret = TotpSecret::Encoded(encoded);
391        let secret_bytes = secret.to_bytes().unwrap();
392        let totp = TOTP::new(
393            Algorithm::SHA1,
394            6,
395            1,
396            30,
397            secret_bytes,
398            Some("RustyClaw".to_string()),
399            "testuser".to_string(),
400        )
401        .unwrap();
402        let now = std::time::SystemTime::now()
403            .duration_since(std::time::UNIX_EPOCH)
404            .unwrap()
405            .as_secs();
406        let code = totp.generate(now);
407
408        assert!(manager.verify_totp(&code).unwrap());
409
410        // Wrong code should fail.
411        assert!(!manager.verify_totp("000000").unwrap());
412
413        // Remove TOTP.
414        manager.remove_totp().unwrap();
415        assert!(!manager.has_totp());
416
417        let _ = std::fs::remove_dir_all(&dir);
418    }
419
420    // ── Typed credential tests ──────────────────────────────────────
421
422    #[test]
423    fn test_store_and_retrieve_api_key() {
424        let dir = temp_dir();
425        let mut m = SecretsManager::new(&dir);
426
427        let entry = SecretEntry {
428            label: "Anthropic".to_string(),
429            kind: SecretKind::ApiKey,
430            policy: AccessPolicy::WithApproval,
431            description: None,
432            disabled: false,
433        };
434        m.store_credential("anthropic_key", &entry, "sk-ant-12345", None)
435            .unwrap();
436
437        let ctx = AccessContext {
438            user_approved: true,
439            ..Default::default()
440        };
441        let (meta, val) = m.get_credential("anthropic_key", &ctx).unwrap().unwrap();
442        assert_eq!(meta.kind, SecretKind::ApiKey);
443        assert_eq!(meta.label, "Anthropic");
444        match val {
445            CredentialValue::Single(v) => assert_eq!(v, "sk-ant-12345"),
446            _ => panic!("Expected Single"),
447        }
448        let _ = std::fs::remove_dir_all(&dir);
449    }
450
451    #[test]
452    fn test_store_and_retrieve_username_password() {
453        let dir = temp_dir();
454        let mut m = SecretsManager::new(&dir);
455
456        let entry = SecretEntry {
457            label: "Registry".to_string(),
458            kind: SecretKind::UsernamePassword,
459            policy: AccessPolicy::Always,
460            description: None,
461            disabled: false,
462        };
463        m.store_credential("registry", &entry, "s3cret", Some("admin"))
464            .unwrap();
465
466        let ctx = AccessContext::default();
467        let (_, val) = m.get_credential("registry", &ctx).unwrap().unwrap();
468        match val {
469            CredentialValue::UserPass { username, password } => {
470                assert_eq!(username, "admin");
471                assert_eq!(password, "s3cret");
472            }
473            _ => panic!("Expected UserPass"),
474        }
475        let _ = std::fs::remove_dir_all(&dir);
476    }
477
478    #[test]
479    fn test_store_http_passkey() {
480        let dir = temp_dir();
481        let mut m = SecretsManager::new(&dir);
482
483        let entry = SecretEntry {
484            label: "WebAuthn passkey".to_string(),
485            kind: SecretKind::HttpPasskey,
486            policy: AccessPolicy::WithAuth,
487            description: Some("FIDO2 credential".to_string()),
488            disabled: false,
489        };
490        m.store_credential("passkey1", &entry, "cred-id-base64", None)
491            .unwrap();
492
493        // Access without authentication should be denied.
494        let ctx = AccessContext {
495            user_approved: true,
496            ..Default::default()
497        };
498        assert!(m.get_credential("passkey1", &ctx).is_err());
499
500        // Access with authentication should succeed.
501        let ctx = AccessContext {
502            authenticated: true,
503            ..Default::default()
504        };
505        let (meta, val) = m.get_credential("passkey1", &ctx).unwrap().unwrap();
506        assert_eq!(meta.kind, SecretKind::HttpPasskey);
507        match val {
508            CredentialValue::Single(v) => assert_eq!(v, "cred-id-base64"),
509            _ => panic!("Expected Single"),
510        }
511        let _ = std::fs::remove_dir_all(&dir);
512    }
513
514    #[test]
515    fn test_generate_ssh_key() {
516        let dir = temp_dir();
517        let mut m = SecretsManager::new(&dir);
518
519        let pubkey = m
520            .generate_ssh_key("rustyclaw_agent", "rustyclaw@agent", AccessPolicy::WithApproval)
521            .unwrap();
522
523        assert!(pubkey.starts_with("ssh-ed25519 "));
524        assert!(pubkey.contains("rustyclaw@agent"));
525
526        // Retrieve via typed API.
527        let ctx = AccessContext {
528            user_approved: true,
529            ..Default::default()
530        };
531        let (meta, val) = m.get_credential("rustyclaw_agent", &ctx).unwrap().unwrap();
532        assert_eq!(meta.kind, SecretKind::SshKey);
533        match val {
534            CredentialValue::SshKeyPair {
535                private_key,
536                public_key,
537            } => {
538                assert!(private_key.contains("BEGIN OPENSSH PRIVATE KEY"));
539                assert!(public_key.starts_with("ssh-ed25519 "));
540            }
541            _ => panic!("Expected SshKeyPair"),
542        }
543
544        // Delete should clean up vault entries.
545        m.delete_credential("rustyclaw_agent").unwrap();
546
547        let _ = std::fs::remove_dir_all(&dir);
548    }
549
550    #[test]
551    fn test_list_credentials() {
552        let dir = temp_dir();
553        let mut m = SecretsManager::new(&dir);
554
555        let e1 = SecretEntry {
556            label: "Key A".to_string(),
557            kind: SecretKind::ApiKey,
558            policy: AccessPolicy::Always,
559            description: None,
560            disabled: false,
561        };
562        let e2 = SecretEntry {
563            label: "Key B".to_string(),
564            kind: SecretKind::Token,
565            policy: AccessPolicy::WithApproval,
566            description: None,
567            disabled: false,
568        };
569        m.store_credential("a", &e1, "val_a", None).unwrap();
570        m.store_credential("b", &e2, "val_b", None).unwrap();
571
572        // Also store a raw legacy secret — should NOT appear in list_credentials.
573        m.store_secret("legacy_key", "legacy_val").unwrap();
574
575        let creds = m.list_credentials();
576        let names: Vec<&str> = creds.iter().map(|(n, _)| n.as_str()).collect();
577        assert!(names.contains(&"a"));
578        assert!(names.contains(&"b"));
579        assert!(!names.contains(&"legacy_key"));
580        assert_eq!(creds.len(), 2);
581
582        let _ = std::fs::remove_dir_all(&dir);
583    }
584
585    #[test]
586    fn test_list_all_entries_includes_legacy_keys() {
587        let dir = temp_dir();
588        let mut m = SecretsManager::new(&dir);
589
590        // Store a typed credential.
591        let entry = SecretEntry {
592            label: "Typed".to_string(),
593            kind: SecretKind::ApiKey,
594            policy: AccessPolicy::Always,
595            description: None,
596            disabled: false,
597        };
598        m.store_credential("typed_one", &entry, "val", None).unwrap();
599
600        // Store legacy bare-key secrets (one known provider, one unknown).
601        m.store_secret("ANTHROPIC_API_KEY", "sk-ant-xxx").unwrap();
602        m.store_secret("MY_CUSTOM_SECRET", "custom-val").unwrap();
603
604        // Store internal keys that should NOT appear.
605        m.store_secret("__init", "").unwrap();
606
607        let all = m.list_all_entries();
608        let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect();
609
610        // Typed credential appears.
611        assert!(names.contains(&"typed_one"));
612        // Known provider legacy key appears with correct label.
613        assert!(names.contains(&"ANTHROPIC_API_KEY"));
614        let anth = all.iter().find(|(n, _)| n == "ANTHROPIC_API_KEY").unwrap();
615        assert_eq!(anth.1.kind, SecretKind::ApiKey);
616        assert!(anth.1.label.contains("Anthropic"));
617
618        // Unknown legacy key appears with humanised label.
619        assert!(names.contains(&"MY_CUSTOM_SECRET"));
620        let custom = all.iter().find(|(n, _)| n == "MY_CUSTOM_SECRET").unwrap();
621        assert_eq!(custom.1.kind, SecretKind::Other);
622
623        // Internal keys excluded.
624        assert!(!names.contains(&"__init"));
625
626        // Sub-keys (cred:*, val:*) excluded.
627        assert!(!names.iter().any(|n| n.starts_with("cred:") || n.starts_with("val:")));
628
629        let _ = std::fs::remove_dir_all(&dir);
630    }
631
632    // ── Access policy tests ─────────────────────────────────────────
633
634    #[test]
635    fn test_policy_always() {
636        let dir = temp_dir();
637        let mut m = SecretsManager::new(&dir);
638        let entry = SecretEntry {
639            label: "open".to_string(),
640            kind: SecretKind::Token,
641            policy: AccessPolicy::Always,
642            description: None,
643            disabled: false,
644        };
645        m.store_credential("open_tok", &entry, "val", None).unwrap();
646
647        // Should succeed with an empty context.
648        let ctx = AccessContext::default();
649        assert!(m.get_credential("open_tok", &ctx).unwrap().is_some());
650
651        let _ = std::fs::remove_dir_all(&dir);
652    }
653
654    #[test]
655    fn test_policy_with_approval_denied() {
656        let dir = temp_dir();
657        let mut m = SecretsManager::new(&dir);
658        let entry = SecretEntry {
659            label: "guarded".to_string(),
660            kind: SecretKind::ApiKey,
661            policy: AccessPolicy::WithApproval,
662            description: None,
663            disabled: false,
664        };
665        m.store_credential("guarded", &entry, "val", None).unwrap();
666
667        // No approval, no agent_access → denied.
668        let ctx = AccessContext::default();
669        assert!(m.get_credential("guarded", &ctx).is_err());
670
671        // With approval → ok.
672        let ctx = AccessContext {
673            user_approved: true,
674            ..Default::default()
675        };
676        assert!(m.get_credential("guarded", &ctx).unwrap().is_some());
677
678        // With agent_access enabled → also ok.
679        m.set_agent_access(true);
680        let ctx = AccessContext::default();
681        assert!(m.get_credential("guarded", &ctx).unwrap().is_some());
682
683        let _ = std::fs::remove_dir_all(&dir);
684    }
685
686    #[test]
687    fn test_policy_with_auth() {
688        let dir = temp_dir();
689        let mut m = SecretsManager::new(&dir);
690        let entry = SecretEntry {
691            label: "high-sec".to_string(),
692            kind: SecretKind::ApiKey,
693            policy: AccessPolicy::WithAuth,
694            description: None,
695            disabled: false,
696        };
697        m.store_credential("hs", &entry, "val", None).unwrap();
698
699        // Even with user_approved, needs authenticated.
700        let ctx = AccessContext {
701            user_approved: true,
702            ..Default::default()
703        };
704        assert!(m.get_credential("hs", &ctx).is_err());
705
706        let ctx = AccessContext {
707            authenticated: true,
708            ..Default::default()
709        };
710        assert!(m.get_credential("hs", &ctx).unwrap().is_some());
711
712        let _ = std::fs::remove_dir_all(&dir);
713    }
714
715    #[test]
716    fn test_policy_skill_only() {
717        let dir = temp_dir();
718        let mut m = SecretsManager::new(&dir);
719        let entry = SecretEntry {
720            label: "deploy-key".to_string(),
721            kind: SecretKind::Token,
722            policy: AccessPolicy::SkillOnly(vec!["deploy".to_string(), "ci".to_string()]),
723            description: None,
724            disabled: false,
725        };
726        m.store_credential("dk", &entry, "val", None).unwrap();
727
728        // No skill → denied.
729        let ctx = AccessContext {
730            user_approved: true,
731            ..Default::default()
732        };
733        assert!(m.get_credential("dk", &ctx).is_err());
734
735        // Wrong skill → denied.
736        let ctx = AccessContext {
737            active_skill: Some("build".to_string()),
738            ..Default::default()
739        };
740        assert!(m.get_credential("dk", &ctx).is_err());
741
742        // Correct skill → ok.
743        let ctx = AccessContext {
744            active_skill: Some("deploy".to_string()),
745            ..Default::default()
746        };
747        assert!(m.get_credential("dk", &ctx).unwrap().is_some());
748
749        let _ = std::fs::remove_dir_all(&dir);
750    }
751
752    #[test]
753    fn test_delete_credential() {
754        let dir = temp_dir();
755        let mut m = SecretsManager::new(&dir);
756        let entry = SecretEntry {
757            label: "tmp".to_string(),
758            kind: SecretKind::Token,
759            policy: AccessPolicy::Always,
760            description: None,
761            disabled: false,
762        };
763        m.store_credential("tmp", &entry, "val", None).unwrap();
764        assert_eq!(m.list_credentials().len(), 1);
765
766        m.delete_credential("tmp").unwrap();
767        assert_eq!(m.list_credentials().len(), 0);
768
769        // get_credential should return None now.
770        let ctx = AccessContext::default();
771        assert!(m.get_credential("tmp", &ctx).unwrap().is_none());
772
773        let _ = std::fs::remove_dir_all(&dir);
774    }
775
776    // ── Web-navigation credential tests ─────────────────────────────
777
778    #[test]
779    fn test_store_and_retrieve_form_autofill() {
780        let dir = temp_dir();
781        let mut m = SecretsManager::new(&dir);
782
783        let entry = SecretEntry {
784            label: "Shipping address".to_string(),
785            kind: SecretKind::FormAutofill,
786            policy: AccessPolicy::WithApproval,
787            description: Some("https://example.com/checkout".to_string()),
788            disabled: false,
789        };
790        let mut fields = std::collections::BTreeMap::new();
791        fields.insert("name".to_string(), "Ada Lovelace".to_string());
792        fields.insert("email".to_string(), "ada@example.com".to_string());
793        fields.insert("phone".to_string(), "+1-555-0100".to_string());
794        fields.insert("address".to_string(), "1 Infinite Loop".to_string());
795
796        m.store_form_autofill("shipping", &entry, &fields).unwrap();
797
798        let ctx = AccessContext {
799            user_approved: true,
800            ..Default::default()
801        };
802        let (meta, val) = m.get_credential("shipping", &ctx).unwrap().unwrap();
803        assert_eq!(meta.kind, SecretKind::FormAutofill);
804        assert_eq!(meta.label, "Shipping address");
805        match val {
806            CredentialValue::FormFields(f) => {
807                assert_eq!(f.len(), 4);
808                assert_eq!(f["name"], "Ada Lovelace");
809                assert_eq!(f["email"], "ada@example.com");
810            }
811            _ => panic!("Expected FormFields"),
812        }
813        let _ = std::fs::remove_dir_all(&dir);
814    }
815
816    #[test]
817    fn test_store_and_retrieve_payment_method() {
818        let dir = temp_dir();
819        let mut m = SecretsManager::new(&dir);
820
821        let entry = SecretEntry {
822            label: "Visa ending 4242".to_string(),
823            kind: SecretKind::PaymentMethod,
824            policy: AccessPolicy::WithAuth,
825            description: None,
826            disabled: false,
827        };
828        let mut extra = std::collections::BTreeMap::new();
829        extra.insert("billing_zip".to_string(), "94025".to_string());
830
831        m.store_payment_method("visa_4242", &entry, "A. Lovelace", "4242424242424242", "12/28", "123", &extra)
832            .unwrap();
833
834        // Needs authentication.
835        let ctx = AccessContext {
836            user_approved: true,
837            ..Default::default()
838        };
839        assert!(m.get_credential("visa_4242", &ctx).is_err());
840
841        let ctx = AccessContext {
842            authenticated: true,
843            ..Default::default()
844        };
845        let (meta, val) = m.get_credential("visa_4242", &ctx).unwrap().unwrap();
846        assert_eq!(meta.kind, SecretKind::PaymentMethod);
847        match val {
848            CredentialValue::PaymentCard {
849                cardholder,
850                number,
851                expiry,
852                cvv,
853                extra,
854            } => {
855                assert_eq!(cardholder, "A. Lovelace");
856                assert_eq!(number, "4242424242424242");
857                assert_eq!(expiry, "12/28");
858                assert_eq!(cvv, "123");
859                assert_eq!(extra["billing_zip"], "94025");
860            }
861            _ => panic!("Expected PaymentCard"),
862        }
863
864        // Delete should clean everything up.
865        m.delete_credential("visa_4242").unwrap();
866        assert_eq!(m.list_credentials().len(), 0);
867
868        let _ = std::fs::remove_dir_all(&dir);
869    }
870
871    #[test]
872    fn test_store_and_retrieve_secure_note() {
873        let dir = temp_dir();
874        let mut m = SecretsManager::new(&dir);
875
876        let entry = SecretEntry {
877            label: "Recovery codes".to_string(),
878            kind: SecretKind::SecureNote,
879            policy: AccessPolicy::WithAuth,
880            description: Some("GitHub 2FA backup codes".to_string()),
881            disabled: false,
882        };
883        let note = "abcde-12345\nfghij-67890\nklmno-13579";
884        m.store_credential("gh_recovery", &entry, note, None).unwrap();
885
886        let ctx = AccessContext {
887            authenticated: true,
888            ..Default::default()
889        };
890        let (meta, val) = m.get_credential("gh_recovery", &ctx).unwrap().unwrap();
891        assert_eq!(meta.kind, SecretKind::SecureNote);
892        assert_eq!(meta.description, Some("GitHub 2FA backup codes".to_string()));
893        match val {
894            CredentialValue::Single(v) => assert_eq!(v, note),
895            _ => panic!("Expected Single"),
896        }
897        let _ = std::fs::remove_dir_all(&dir);
898    }
899
900    #[test]
901    fn test_form_autofill_delete_cleans_fields() {
902        let dir = temp_dir();
903        let mut m = SecretsManager::new(&dir);
904
905        let entry = SecretEntry {
906            label: "Login form".to_string(),
907            kind: SecretKind::FormAutofill,
908            policy: AccessPolicy::Always,
909            description: None,
910            disabled: false,
911        };
912        let mut fields = std::collections::BTreeMap::new();
913        fields.insert("user".to_string(), "alice".to_string());
914        m.store_form_autofill("login", &entry, &fields).unwrap();
915        assert_eq!(m.list_credentials().len(), 1);
916
917        m.delete_credential("login").unwrap();
918        assert_eq!(m.list_credentials().len(), 0);
919
920        // The :fields sub-key should also be gone.
921        m.set_agent_access(true);
922        let raw = m.get_secret("val:login:fields", false).unwrap();
923        assert_eq!(raw, None);
924
925        let _ = std::fs::remove_dir_all(&dir);
926    }
927
928    #[test]
929    fn test_disable_and_reenable_credential() {
930        let dir = temp_dir();
931        let mut m = SecretsManager::new(&dir);
932
933        let entry = SecretEntry {
934            label: "my key".to_string(),
935            kind: SecretKind::ApiKey,
936            policy: AccessPolicy::Always,
937            description: None,
938            disabled: false,
939        };
940        m.store_credential("k", &entry, "secret", None).unwrap();
941
942        // Initially accessible.
943        let ctx = AccessContext::default();
944        assert!(m.get_credential("k", &ctx).unwrap().is_some());
945
946        // Disable it — access should fail.
947        m.set_credential_disabled("k", true).unwrap();
948        assert!(m.get_credential("k", &ctx).is_err());
949
950        // Still listed.
951        let creds = m.list_credentials();
952        assert_eq!(creds.len(), 1);
953        assert!(creds[0].1.disabled);
954
955        // Re-enable — access should work again.
956        m.set_credential_disabled("k", false).unwrap();
957        assert!(m.get_credential("k", &ctx).unwrap().is_some());
958
959        let _ = std::fs::remove_dir_all(&dir);
960    }
961
962    #[test]
963    fn test_disable_legacy_key_promotes_to_typed() {
964        let dir = temp_dir();
965        let mut m = SecretsManager::new(&dir);
966
967        // Store a bare-key secret (no cred: metadata).
968        m.store_secret("MY_BARE_KEY", "bare_val").unwrap();
969
970        // Disabling it should create a cred: entry.
971        m.set_credential_disabled("MY_BARE_KEY", true).unwrap();
972
973        let all = m.list_all_entries();
974        let bare = all.iter().find(|(n, _)| n == "MY_BARE_KEY").unwrap();
975        assert!(bare.1.disabled);
976
977        let _ = std::fs::remove_dir_all(&dir);
978    }
979
980    #[test]
981    fn test_set_credential_policy() {
982        let dir = temp_dir();
983        let mut m = SecretsManager::new(&dir);
984
985        let entry = SecretEntry {
986            label: "my key".to_string(),
987            kind: SecretKind::ApiKey,
988            policy: AccessPolicy::WithApproval,
989            description: None,
990            disabled: false,
991        };
992        m.store_credential("k", &entry, "secret", None).unwrap();
993
994        // Default policy is ASK (WithApproval).
995        let creds = m.list_credentials();
996        assert_eq!(creds[0].1.policy, AccessPolicy::WithApproval);
997
998        // Change to OPEN.
999        m.set_credential_policy("k", AccessPolicy::Always).unwrap();
1000        let creds = m.list_credentials();
1001        assert_eq!(creds[0].1.policy, AccessPolicy::Always);
1002
1003        // Change to AUTH.
1004        m.set_credential_policy("k", AccessPolicy::WithAuth).unwrap();
1005        let creds = m.list_credentials();
1006        assert_eq!(creds[0].1.policy, AccessPolicy::WithAuth);
1007
1008        // Change to SKILL.
1009        m.set_credential_policy("k", AccessPolicy::SkillOnly(vec!["web".to_string()]))
1010            .unwrap();
1011        let creds = m.list_credentials();
1012        assert_eq!(
1013            creds[0].1.policy,
1014            AccessPolicy::SkillOnly(vec!["web".to_string()])
1015        );
1016
1017        // Change back to ASK.
1018        m.set_credential_policy("k", AccessPolicy::WithApproval).unwrap();
1019        let creds = m.list_credentials();
1020        assert_eq!(creds[0].1.policy, AccessPolicy::WithApproval);
1021
1022        let _ = std::fs::remove_dir_all(&dir);
1023    }
1024
1025    #[test]
1026    fn test_set_policy_legacy_key_promotes_to_typed() {
1027        let dir = temp_dir();
1028        let mut m = SecretsManager::new(&dir);
1029
1030        // Store a bare-key secret (no cred: metadata).
1031        m.store_secret("LEGACY_KEY", "legacy_val").unwrap();
1032
1033        // Setting policy should create a cred: entry.
1034        m.set_credential_policy("LEGACY_KEY", AccessPolicy::Always).unwrap();
1035
1036        let all = m.list_all_entries();
1037        let entry = all.iter().find(|(n, _)| n == "LEGACY_KEY").unwrap();
1038        assert_eq!(entry.1.policy, AccessPolicy::Always);
1039
1040        let _ = std::fs::remove_dir_all(&dir);
1041    }
1042}