1use std::path::{Component, Path, PathBuf};
2
3use anyhow::{Result, bail};
4use serde::{Deserialize, Serialize};
5
6use crate::ProviderKind;
7
8pub const EXTERNAL_CREDENTIAL_CONSENT_VERSION: u32 = 1;
10
11pub const EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS: &str = "read this exact file; no refresh, identity-provider or discovery requests, external-file writes, or rewrites; normal requests to the explicitly selected provider may use the token";
13
14#[must_use]
21pub fn quote_os_path(path: &Path) -> String {
22 quote_os_path_inner(path)
23}
24
25#[cfg(unix)]
26fn quote_os_path_inner(path: &Path) -> String {
27 use std::os::unix::ffi::OsStrExt as _;
28 let bytes = path.as_os_str().as_bytes();
29 if let Ok(text) = std::str::from_utf8(bytes) {
30 return quote_path_text(text);
31 }
32 let mut out = String::from("\"");
33 for byte in bytes {
34 match byte {
35 b'"' => out.push_str("\\\""),
36 b'\\' => out.push_str("\\\\"),
37 0x20..=0x7e => out.push(char::from(*byte)),
38 _ => out.push_str(&format!("\\x{byte:02x}")),
39 }
40 }
41 out.push('"');
42 out
43}
44
45#[cfg(windows)]
46fn quote_os_path_inner(path: &Path) -> String {
47 use std::os::windows::ffi::OsStrExt as _;
48 let mut out = String::from("\"");
49 for decoded in char::decode_utf16(path.as_os_str().encode_wide()) {
50 match decoded {
51 Ok(character) => push_escaped_path_character(&mut out, character),
52 Err(error) => out.push_str(&format!("\\u{{{:04x}}}", error.unpaired_surrogate())),
53 }
54 }
55 out.push('"');
56 out
57}
58
59#[cfg(not(any(unix, windows)))]
60fn quote_os_path_inner(path: &Path) -> String {
61 quote_path_text(&path.to_string_lossy())
62}
63
64#[cfg(not(windows))]
65fn quote_path_text(text: &str) -> String {
66 let mut out = String::with_capacity(text.len() + 2);
67 out.push('"');
68 for character in text.chars() {
69 push_escaped_path_character(&mut out, character);
70 }
71 out.push('"');
72 out
73}
74
75fn push_escaped_path_character(out: &mut String, character: char) {
76 match character {
77 '"' => out.push_str("\\\""),
78 '\\' => out.push_str("\\\\"),
79 '\n' => out.push_str("\\n"),
80 '\r' => out.push_str("\\r"),
81 '\t' => out.push_str("\\t"),
82 '\u{1b}' => out.push_str("\\x1b"),
83 character if character.is_control() || is_bidi_format_control(character) => {
84 out.extend(character.escape_unicode());
85 }
86 character => out.push(character),
87 }
88}
89
90fn is_bidi_format_control(character: char) -> bool {
91 matches!(
92 character,
93 '\u{061c}'
94 | '\u{200e}'
95 | '\u{200f}'
96 | '\u{2028}'
97 | '\u{2029}'
98 | '\u{202a}'..='\u{202e}'
99 | '\u{2066}'..='\u{2069}'
100 )
101}
102
103pub fn resolve_external_credential_path(path: impl AsRef<Path>) -> Result<PathBuf> {
108 let path = path.as_ref();
109 let absolute = if path.is_absolute() {
110 path.to_path_buf()
111 } else {
112 std::env::current_dir()
113 .map_err(|err| anyhow::anyhow!("resolving external credential path: {err}"))?
114 .join(path)
115 };
116
117 let mut normalized = PathBuf::new();
122 for component in absolute.components() {
123 match component {
124 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
125 Component::RootDir => normalized.push(component.as_os_str()),
126 Component::CurDir => {}
127 Component::ParentDir => {
128 if !normalized.pop() {
129 bail!(
130 "external credential path escapes its absolute root: {}",
131 quote_os_path(&absolute)
132 );
133 }
134 }
135 Component::Normal(part) => normalized.push(part),
136 }
137 }
138 if !normalized.is_absolute() {
139 bail!(
140 "external credential path must resolve to an absolute path: {}",
141 quote_os_path(&normalized)
142 );
143 }
144 Ok(normalized)
145}
146
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ExternalCredentialAccess {
151 #[default]
153 Disabled,
154 ReadOnly,
156 Managed,
158}
159
160impl ExternalCredentialAccess {
161 #[must_use]
162 pub const fn as_str(self) -> &'static str {
163 match self {
164 Self::Disabled => "disabled",
165 Self::ReadOnly => "read_only",
166 Self::Managed => "managed",
167 }
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum ExternalCredentialSource {
175 CodexCli,
176 KimiCodeCli,
177 GrokCli,
178 DshCli,
180 AgyCli,
182}
183
184#[must_use]
190pub fn default_dsh_credentials_path() -> PathBuf {
191 let home = match std::env::var_os("DSH_HOME") {
192 Some(value) if !value.is_empty() => PathBuf::from(value),
193 _ => codewhale_paths::user_home()
194 .unwrap_or_else(|| PathBuf::from("."))
195 .join(".dsh"),
196 };
197 home.join(".credentials.yaml")
198}
199
200#[must_use]
205pub fn default_agy_credentials_path() -> PathBuf {
206 let base = match std::env::var_os("ANTIGRAVITY_STATE_DIR") {
207 Some(value) if !value.is_empty() => PathBuf::from(value),
208 _ => agy_profile_base(),
209 };
210 base.join("User").join("globalStorage").join("state.vscdb")
211}
212
213#[cfg(target_os = "macos")]
214fn agy_profile_base() -> PathBuf {
215 codewhale_paths::user_home()
216 .unwrap_or_else(|| PathBuf::from("."))
217 .join("Library/Application Support/Antigravity")
218}
219
220#[cfg(all(unix, not(target_os = "macos")))]
221fn agy_profile_base() -> PathBuf {
222 match std::env::var_os("XDG_CONFIG_HOME") {
223 Some(value) if !value.is_empty() => PathBuf::from(value),
224 _ => codewhale_paths::user_home()
225 .unwrap_or_else(|| PathBuf::from("."))
226 .join(".config"),
227 }
228 .join("Antigravity")
229}
230
231#[cfg(windows)]
232fn agy_profile_base() -> PathBuf {
233 match std::env::var_os("APPDATA") {
234 Some(value) if !value.is_empty() => PathBuf::from(value),
235 _ => codewhale_paths::user_home().unwrap_or_else(|| PathBuf::from(".")),
236 }
237 .join("Antigravity")
238}
239
240impl ExternalCredentialSource {
241 #[must_use]
242 pub const fn as_str(self) -> &'static str {
243 match self {
244 Self::CodexCli => "codex_cli",
245 Self::KimiCodeCli => "kimi_code_cli",
246 Self::GrokCli => "grok_cli",
247 Self::DshCli => "dsh_cli",
248 Self::AgyCli => "agy_cli",
249 }
250 }
251
252 #[must_use]
254 pub const fn owner_label(self) -> &'static str {
255 match self {
256 Self::CodexCli => "Codex CLI",
257 Self::KimiCodeCli => "Kimi Code CLI",
258 Self::GrokCli => "Grok CLI",
259 Self::DshCli => "DeepSeek Harness",
260 Self::AgyCli => "Antigravity CLI",
261 }
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ExternalCredentialConsentStatus {
268 pub access: ExternalCredentialAccess,
269 pub provider: String,
270 pub source: ExternalCredentialSource,
271 pub owner: &'static str,
272 pub path: PathBuf,
273 pub consent_version: u32,
274 pub configured: bool,
275 pub scope_valid: bool,
276 pub ambient_path_changed: bool,
279 pub route_state: &'static str,
280 pub semantics: &'static str,
281 pub revoke_command: String,
282}
283
284impl ExternalCredentialConsentStatus {
285 #[must_use]
288 pub fn ambient_path_warning(&self) -> Option<String> {
289 self.ambient_path_changed.then(|| {
290 format!(
291 "warning: ambient {} credential path changed; consent remains pinned to {} and was not redirected",
292 self.owner,
293 quote_os_path(&self.path)
294 )
295 })
296 }
297}
298
299#[must_use]
302pub fn external_credential_consent_status(
303 consent: Option<&ExternalCredentialConsentToml>,
304 provider: ProviderKind,
305 source: ExternalCredentialSource,
306 expected_path: &Path,
307 active_provider: ProviderKind,
308) -> ExternalCredentialConsentStatus {
309 let configured = consent.is_some();
310 let access = consent.map_or(ExternalCredentialAccess::Disabled, |value| value.access);
311 let reported_provider = provider.as_str().to_string();
315 let reported_source = source;
316 let reported_path = consent
317 .map(|value| value.path.clone())
318 .unwrap_or_else(|| expected_path.to_path_buf());
319 let consent_version = consent.map_or(EXTERNAL_CREDENTIAL_CONSENT_VERSION, |value| {
320 value.consent_version
321 });
322 let scope_valid = consent.is_some_and(|value| {
323 value
324 .validate_read_scope(provider, source, &value.path)
325 .is_ok()
326 });
327 let ambient_path_changed = consent.is_some_and(|value| value.path != expected_path);
328 let active =
329 provider == active_provider && access == ExternalCredentialAccess::ReadOnly && scope_valid;
330 let route_state = if active { "active" } else { "dormant" };
331 let semantics = match access {
332 ExternalCredentialAccess::Disabled => {
333 "disabled; no external-credential probing, reading, refresh, discovery, identity-provider or network acquisition, writes, or rewrites; normal requests to the explicitly selected provider may use Codewhale-owned credentials"
334 }
335 ExternalCredentialAccess::ReadOnly => EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
336 ExternalCredentialAccess::Managed => {
337 "managed access unavailable; no schema-safe preservation adapter"
338 }
339 };
340
341 ExternalCredentialConsentStatus {
342 access,
343 provider: reported_provider,
344 source: reported_source,
345 owner: reported_source.owner_label(),
346 path: reported_path,
347 consent_version,
348 configured,
349 scope_valid,
350 ambient_path_changed,
351 route_state,
352 semantics,
353 revoke_command: format!(
354 "codewhale auth external-revoke --provider {}",
355 provider.as_str()
356 ),
357 }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365#[serde(deny_unknown_fields)]
366pub struct ExternalCredentialConsentToml {
367 pub access: ExternalCredentialAccess,
368 pub provider: String,
369 pub source: ExternalCredentialSource,
370 pub path: PathBuf,
371 pub consent_version: u32,
372}
373
374impl ExternalCredentialConsentToml {
375 #[must_use]
376 pub fn read_only(
377 provider: ProviderKind,
378 source: ExternalCredentialSource,
379 path: PathBuf,
380 ) -> Self {
381 Self {
382 access: ExternalCredentialAccess::ReadOnly,
383 provider: provider.as_str().to_string(),
384 source,
385 path,
386 consent_version: EXTERNAL_CREDENTIAL_CONSENT_VERSION,
387 }
388 }
389
390 pub fn validate_read_scope(
396 &self,
397 provider: ProviderKind,
398 source: ExternalCredentialSource,
399 resolved_path: &Path,
400 ) -> Result<()> {
401 if self.access == ExternalCredentialAccess::Disabled {
402 bail!(
403 "external credential access is disabled for {}",
404 provider.as_str()
405 );
406 }
407 if self.access == ExternalCredentialAccess::Managed {
408 bail!(
409 "managed external credential access is unsupported for {}; no schema-safe preservation adapter is available",
410 provider.as_str()
411 );
412 }
413 if self.consent_version != EXTERNAL_CREDENTIAL_CONSENT_VERSION {
414 bail!(
415 "external credential consent for {} uses unsupported version {}; revoke and consent again",
416 provider.as_str(),
417 self.consent_version
418 );
419 }
420 if self.provider != provider.as_str() {
421 bail!(
422 "external credential consent is scoped to provider {:?}, not {}",
423 self.provider,
424 provider.as_str()
425 );
426 }
427 if self.source != source {
428 bail!(
429 "external credential consent source mismatch for {} (expected {})",
430 provider.as_str(),
431 source.as_str()
432 );
433 }
434 if !self.path.is_absolute() {
435 bail!(
436 "external credential consent path for {} must be absolute",
437 provider.as_str()
438 );
439 }
440 let normalized = resolve_external_credential_path(&self.path)?;
441 if normalized != self.path {
442 bail!(
443 "external credential consent path for {} must be lexically normalized: {}",
444 provider.as_str(),
445 quote_os_path(&self.path)
446 );
447 }
448 if self.path != resolved_path {
449 bail!(
450 "external credential path changed for {}; consent covers {}, current path is {}",
451 provider.as_str(),
452 quote_os_path(&self.path),
453 quote_os_path(resolved_path)
454 );
455 }
456 Ok(())
457 }
458
459 pub fn read_grant(
462 &self,
463 provider: ProviderKind,
464 source: ExternalCredentialSource,
465 resolved_path: &Path,
466 ) -> Result<ExternalCredentialReadGrant> {
467 self.validate_read_scope(provider, source, resolved_path)?;
468 Ok(ExternalCredentialReadGrant {
469 provider,
470 source,
471 path: resolved_path.to_path_buf(),
472 consent_version: self.consent_version,
473 })
474 }
475}
476
477#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct ExternalCredentialReadGrant {
480 provider: ProviderKind,
481 source: ExternalCredentialSource,
482 path: PathBuf,
483 consent_version: u32,
484}
485
486impl ExternalCredentialReadGrant {
487 #[must_use]
488 pub fn provider(&self) -> ProviderKind {
489 self.provider
490 }
491
492 #[must_use]
493 pub fn source(&self) -> ExternalCredentialSource {
494 self.source
495 }
496
497 #[must_use]
498 pub fn path(&self) -> &Path {
499 &self.path
500 }
501
502 #[must_use]
503 pub fn consent_version(&self) -> u32 {
504 self.consent_version
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 fn absolute_test_path(file: &str) -> PathBuf {
513 if cfg!(windows) {
514 PathBuf::from(format!(r"C:\Users\test\{file}"))
515 } else {
516 PathBuf::from(format!("/tmp/{file}"))
517 }
518 }
519
520 #[test]
521 fn default_dsh_credentials_path_uses_dsh_home_or_dot_dsh() {
522 let previous = std::env::var_os("DSH_HOME");
523 unsafe {
524 std::env::set_var("DSH_HOME", "/opt/dsh-home");
525 }
526 let with_home = default_dsh_credentials_path();
527 match previous {
528 Some(value) => unsafe { std::env::set_var("DSH_HOME", value) },
529 None => unsafe { std::env::remove_var("DSH_HOME") },
530 }
531 assert_eq!(with_home, PathBuf::from("/opt/dsh-home/.credentials.yaml"));
532 assert_eq!(ExternalCredentialSource::DshCli.as_str(), "dsh_cli");
533 assert_eq!(
534 ExternalCredentialSource::DshCli.owner_label(),
535 "DeepSeek Harness"
536 );
537 }
538
539 #[test]
540 fn disclosed_paths_are_absolute_and_lexically_normalized_without_io() {
541 let resolved =
542 resolve_external_credential_path("one/./two/../auth.json").expect("lexical resolution");
543 assert!(resolved.is_absolute());
544 assert!(
545 resolved.ends_with(Path::new("one/auth.json")),
546 "{}",
547 resolved.display()
548 );
549 assert!(!resolved.to_string_lossy().contains("/./"));
550 assert!(!resolved.to_string_lossy().contains("/../"));
551 }
552
553 #[test]
554 fn structural_status_reports_full_scope_without_io() {
555 let path = absolute_test_path("codex-auth.json");
556 let consent = ExternalCredentialConsentToml::read_only(
557 ProviderKind::OpenaiCodex,
558 ExternalCredentialSource::CodexCli,
559 path.clone(),
560 );
561 let active = external_credential_consent_status(
562 Some(&consent),
563 ProviderKind::OpenaiCodex,
564 ExternalCredentialSource::CodexCli,
565 &path,
566 ProviderKind::OpenaiCodex,
567 );
568 assert_eq!(active.access, ExternalCredentialAccess::ReadOnly);
569 assert_eq!(active.owner, "Codex CLI");
570 assert_eq!(active.path, path);
571 assert_eq!(active.route_state, "active");
572 assert!(active.scope_valid);
573 assert!(active.semantics.contains("no refresh"));
574 assert_eq!(
575 active.revoke_command,
576 "codewhale auth external-revoke --provider openai-codex"
577 );
578
579 let changed_path = absolute_test_path("moved-auth.json");
580 let pinned = external_credential_consent_status(
581 Some(&consent),
582 ProviderKind::OpenaiCodex,
583 ExternalCredentialSource::CodexCli,
584 &changed_path,
585 ProviderKind::OpenaiCodex,
586 );
587 assert!(pinned.scope_valid);
588 assert_eq!(pinned.route_state, "active");
589 assert!(pinned.ambient_path_changed);
590 assert_eq!(pinned.path, path, "report the pinned persisted grant path");
591 let warning = pinned
592 .ambient_path_warning()
593 .expect("ambient mismatch warning");
594 assert!(warning.contains("remains pinned"), "{warning}");
595 assert!(warning.contains("e_os_path(&path)), "{warning}");
596 }
597
598 #[test]
599 fn displayed_paths_escape_terminal_and_bidi_controls_on_one_line() {
600 let path = PathBuf::from(
601 "/safe/line\nmanaged\u{1b}[2J\u{2028}first\u{2029}second\u{202e}name.json",
602 );
603 let quoted = quote_os_path(&path);
604 assert!(quoted.starts_with('"') && quoted.ends_with('"'));
605 assert!(quoted.contains("\\n"), "{quoted}");
606 assert!(quoted.contains("\\x1b"), "{quoted}");
607 assert!(quoted.contains("\\u{2028}"), "{quoted}");
608 assert!(quoted.contains("\\u{2029}"), "{quoted}");
609 assert!(quoted.contains("\\u{202e}"), "{quoted}");
610 assert!(!quoted.contains('\n'));
611 assert!(!quoted.contains('\u{1b}'));
612 assert!(!quoted.contains('\u{2028}'));
613 assert!(!quoted.contains('\u{2029}'));
614 assert!(!quoted.contains('\u{202e}'));
615 }
616
617 #[test]
618 fn disabled_disclosure_does_not_imply_normal_provider_network_is_disabled() {
619 let path = absolute_test_path("codex-auth.json");
620 let status = external_credential_consent_status(
621 None,
622 ProviderKind::OpenaiCodex,
623 ExternalCredentialSource::CodexCli,
624 &path,
625 ProviderKind::OpenaiCodex,
626 );
627 assert!(status.semantics.contains("no external-credential"));
628 assert!(status.semantics.contains("normal requests"));
629 assert!(!status.semantics.contains("no network requests"));
630 }
631
632 #[test]
633 fn read_grant_requires_exact_provider_source_path_and_version() {
634 let path = absolute_test_path("codex-auth.json");
635 let consent = ExternalCredentialConsentToml::read_only(
636 ProviderKind::OpenaiCodex,
637 ExternalCredentialSource::CodexCli,
638 path.clone(),
639 );
640
641 let grant = consent
642 .read_grant(
643 ProviderKind::OpenaiCodex,
644 ExternalCredentialSource::CodexCli,
645 &path,
646 )
647 .expect("exact consent tuple");
648 assert_eq!(grant.path(), path);
649
650 assert!(
651 consent
652 .read_grant(ProviderKind::Xai, ExternalCredentialSource::CodexCli, &path)
653 .is_err()
654 );
655 assert!(
656 consent
657 .read_grant(
658 ProviderKind::OpenaiCodex,
659 ExternalCredentialSource::GrokCli,
660 &path
661 )
662 .is_err()
663 );
664 assert!(
665 consent
666 .read_grant(
667 ProviderKind::OpenaiCodex,
668 ExternalCredentialSource::CodexCli,
669 &path.with_file_name("other.json")
670 )
671 .is_err()
672 );
673 }
674
675 #[test]
676 fn persisted_consent_path_must_be_lexically_normalized() {
677 let raw_path = if cfg!(windows) {
678 PathBuf::from(r"C:\Users\test\credentials\..\auth.json")
679 } else {
680 PathBuf::from("/tmp/credentials/../auth.json")
681 };
682 let consent = ExternalCredentialConsentToml::read_only(
683 ProviderKind::Xai,
684 ExternalCredentialSource::GrokCli,
685 raw_path.clone(),
686 );
687 assert!(
688 consent
689 .read_grant(
690 ProviderKind::Xai,
691 ExternalCredentialSource::GrokCli,
692 &raw_path
693 )
694 .is_err()
695 );
696 }
697
698 #[test]
699 fn managed_consent_is_explicitly_unsupported_without_an_adapter() {
700 let path = absolute_test_path("grok-auth.json");
701 let mut consent = ExternalCredentialConsentToml::read_only(
702 ProviderKind::Xai,
703 ExternalCredentialSource::GrokCli,
704 path.clone(),
705 );
706 consent.access = ExternalCredentialAccess::Managed;
707
708 let error = consent
709 .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path)
710 .expect_err("managed access must fail closed");
711 assert!(
712 error
713 .to_string()
714 .contains("schema-safe preservation adapter")
715 );
716 }
717
718 #[test]
719 fn consent_round_trips_every_scope_field() {
720 let path = absolute_test_path("codex-auth.json");
721 let consent = ExternalCredentialConsentToml::read_only(
722 ProviderKind::OpenaiCodex,
723 ExternalCredentialSource::CodexCli,
724 path,
725 );
726
727 let encoded = toml::to_string(&consent).expect("serialize consent");
728 let decoded: ExternalCredentialConsentToml =
729 toml::from_str(&encoded).expect("deserialize consent");
730 assert_eq!(decoded, consent);
731 assert!(encoded.contains("access = \"read_only\""));
732 assert!(encoded.contains("provider = \"openai-codex\""));
733 assert!(encoded.contains("source = \"codex_cli\""));
734 assert!(encoded.contains("consent_version = 1"));
735 }
736
737 #[test]
738 fn disabled_stale_and_relative_consent_fail_before_a_grant() {
739 let path = absolute_test_path("grok-auth.json");
740 let mut consent = ExternalCredentialConsentToml::read_only(
741 ProviderKind::Xai,
742 ExternalCredentialSource::GrokCli,
743 path.clone(),
744 );
745
746 consent.access = ExternalCredentialAccess::Disabled;
747 assert!(
748 consent
749 .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path)
750 .expect_err("disabled consent")
751 .to_string()
752 .contains("disabled")
753 );
754
755 consent.access = ExternalCredentialAccess::ReadOnly;
756 consent.consent_version = EXTERNAL_CREDENTIAL_CONSENT_VERSION + 1;
757 assert!(
758 consent
759 .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path)
760 .expect_err("stale consent")
761 .to_string()
762 .contains("unsupported version")
763 );
764
765 consent.consent_version = EXTERNAL_CREDENTIAL_CONSENT_VERSION;
766 consent.path = PathBuf::from("relative/auth.json");
767 assert!(
768 consent
769 .read_grant(
770 ProviderKind::Xai,
771 ExternalCredentialSource::GrokCli,
772 Path::new("relative/auth.json"),
773 )
774 .expect_err("relative path")
775 .to_string()
776 .contains("must be absolute")
777 );
778 }
779}