Skip to main content

codewhale_config/
external_credentials.rs

1use std::path::{Component, Path, PathBuf};
2
3use anyhow::{Result, bail};
4use serde::{Deserialize, Serialize};
5
6use crate::ProviderKind;
7
8/// Schema version for informed consent to another CLI's credential file.
9pub const EXTERNAL_CREDENTIAL_CONSENT_VERSION: u32 = 1;
10
11/// The complete side-effect contract for read-only external credentials.
12pub 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/// Quote an OS path for terminals, logs, JSON display fields, and errors.
15///
16/// The result is always one line. Terminal controls, line separators, bidi
17/// formatting controls, quotes, and backslashes are escaped. Unix paths keep
18/// non-UTF-8 bytes exact as `\xNN`; Windows preserves unpaired UTF-16 units as
19/// `\u{NNNN}`.
20#[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
103/// Resolve a user-selected path without touching the filesystem.
104///
105/// Consent is bound to the exact logical path, so this intentionally avoids
106/// canonicalization (which would stat the candidate before consent exists).
107pub 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    // Normalize only lexical `.` / `..` components. Canonicalization would
118    // inspect a credential path before consent exists and would also silently
119    // bless a symlink target. The secure reader rejects symlink/reparse-point
120    // components when the granted capability is actually consumed.
121    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/// The side-effect envelope Codewhale may use for an external credential.
148#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ExternalCredentialAccess {
151    /// Do not inspect or access the external credential store.
152    #[default]
153    Disabled,
154    /// Read the exact selected file without refreshing or rewriting it.
155    ReadOnly,
156    /// Permit a documented preservation adapter to refresh and rewrite it.
157    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/// External credential owners supported by the consent schema.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum ExternalCredentialSource {
175    CodexCli,
176    KimiCodeCli,
177    GrokCli,
178}
179
180impl ExternalCredentialSource {
181    #[must_use]
182    pub const fn as_str(self) -> &'static str {
183        match self {
184            Self::CodexCli => "codex_cli",
185            Self::KimiCodeCli => "kimi_code_cli",
186            Self::GrokCli => "grok_cli",
187        }
188    }
189
190    /// Human-facing owner name used in informed-consent disclosures.
191    #[must_use]
192    pub const fn owner_label(self) -> &'static str {
193        match self {
194            Self::CodexCli => "Codex CLI",
195            Self::KimiCodeCli => "Kimi Code CLI",
196            Self::GrokCli => "Grok CLI",
197        }
198    }
199}
200
201/// Side-effect-free projection used by picker, config, and doctor surfaces.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct ExternalCredentialConsentStatus {
204    pub access: ExternalCredentialAccess,
205    pub provider: String,
206    pub source: ExternalCredentialSource,
207    pub owner: &'static str,
208    pub path: PathBuf,
209    pub consent_version: u32,
210    pub configured: bool,
211    pub scope_valid: bool,
212    /// True when the ambient CLI path now differs from the persisted pinned
213    /// path. This is informational; it never redirects or deactivates consent.
214    pub ambient_path_changed: bool,
215    pub route_state: &'static str,
216    pub semantics: &'static str,
217    pub revoke_command: String,
218}
219
220impl ExternalCredentialConsentStatus {
221    /// Warn without displaying the untrusted ambient replacement. The
222    /// persisted path remains authoritative and is escaped for one line.
223    #[must_use]
224    pub fn ambient_path_warning(&self) -> Option<String> {
225        self.ambient_path_changed.then(|| {
226            format!(
227                "warning: ambient {} credential path changed; consent remains pinned to {} and was not redirected",
228                self.owner,
229                quote_os_path(&self.path)
230            )
231        })
232    }
233}
234
235/// Describe persisted external-credential policy without filesystem or network
236/// access. `expected_path` is resolved lexically by the caller.
237#[must_use]
238pub fn external_credential_consent_status(
239    consent: Option<&ExternalCredentialConsentToml>,
240    provider: ProviderKind,
241    source: ExternalCredentialSource,
242    expected_path: &Path,
243    active_provider: ProviderKind,
244) -> ExternalCredentialConsentStatus {
245    let configured = consent.is_some();
246    let access = consent.map_or(ExternalCredentialAccess::Disabled, |value| value.access);
247    // User-facing status identifies the route being inspected. Persisted
248    // provider/source fields are untrusted config input and are represented by
249    // `scope_valid` rather than echoed into a terminal surface.
250    let reported_provider = provider.as_str().to_string();
251    let reported_source = source;
252    let reported_path = consent
253        .map(|value| value.path.clone())
254        .unwrap_or_else(|| expected_path.to_path_buf());
255    let consent_version = consent.map_or(EXTERNAL_CREDENTIAL_CONSENT_VERSION, |value| {
256        value.consent_version
257    });
258    let scope_valid = consent.is_some_and(|value| {
259        value
260            .validate_read_scope(provider, source, &value.path)
261            .is_ok()
262    });
263    let ambient_path_changed = consent.is_some_and(|value| value.path != expected_path);
264    let active =
265        provider == active_provider && access == ExternalCredentialAccess::ReadOnly && scope_valid;
266    let route_state = if active { "active" } else { "dormant" };
267    let semantics = match access {
268        ExternalCredentialAccess::Disabled => {
269            "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"
270        }
271        ExternalCredentialAccess::ReadOnly => EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
272        ExternalCredentialAccess::Managed => {
273            "managed access unavailable; no schema-safe preservation adapter"
274        }
275    };
276
277    ExternalCredentialConsentStatus {
278        access,
279        provider: reported_provider,
280        source: reported_source,
281        owner: reported_source.owner_label(),
282        path: reported_path,
283        consent_version,
284        configured,
285        scope_valid,
286        ambient_path_changed,
287        route_state,
288        semantics,
289        revoke_command: format!(
290            "codewhale auth external-revoke --provider {}",
291            provider.as_str()
292        ),
293    }
294}
295
296/// Persisted, provider-scoped consent for one exact external credential file.
297///
298/// Provider and source are repeated intentionally. A copied provider table or
299/// a future source-path remap must fail closed instead of inheriting authority.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct ExternalCredentialConsentToml {
303    pub access: ExternalCredentialAccess,
304    pub provider: String,
305    pub source: ExternalCredentialSource,
306    pub path: PathBuf,
307    pub consent_version: u32,
308}
309
310impl ExternalCredentialConsentToml {
311    #[must_use]
312    pub fn read_only(
313        provider: ProviderKind,
314        source: ExternalCredentialSource,
315        path: PathBuf,
316    ) -> Self {
317        Self {
318            access: ExternalCredentialAccess::ReadOnly,
319            provider: provider.as_str().to_string(),
320            source,
321            path,
322            consent_version: EXTERNAL_CREDENTIAL_CONSENT_VERSION,
323        }
324    }
325
326    /// Validate that this record is a current read-only consent for one exact
327    /// provider/source/path tuple without minting an I/O capability.
328    ///
329    /// This is intentionally side-effect free so inventory and picker surfaces
330    /// can acknowledge dormant consent without inspecting the external file.
331    pub fn validate_read_scope(
332        &self,
333        provider: ProviderKind,
334        source: ExternalCredentialSource,
335        resolved_path: &Path,
336    ) -> Result<()> {
337        if self.access == ExternalCredentialAccess::Disabled {
338            bail!(
339                "external credential access is disabled for {}",
340                provider.as_str()
341            );
342        }
343        if self.access == ExternalCredentialAccess::Managed {
344            bail!(
345                "managed external credential access is unsupported for {}; no schema-safe preservation adapter is available",
346                provider.as_str()
347            );
348        }
349        if self.consent_version != EXTERNAL_CREDENTIAL_CONSENT_VERSION {
350            bail!(
351                "external credential consent for {} uses unsupported version {}; revoke and consent again",
352                provider.as_str(),
353                self.consent_version
354            );
355        }
356        if self.provider != provider.as_str() {
357            bail!(
358                "external credential consent is scoped to provider {:?}, not {}",
359                self.provider,
360                provider.as_str()
361            );
362        }
363        if self.source != source {
364            bail!(
365                "external credential consent source mismatch for {} (expected {})",
366                provider.as_str(),
367                source.as_str()
368            );
369        }
370        if !self.path.is_absolute() {
371            bail!(
372                "external credential consent path for {} must be absolute",
373                provider.as_str()
374            );
375        }
376        let normalized = resolve_external_credential_path(&self.path)?;
377        if normalized != self.path {
378            bail!(
379                "external credential consent path for {} must be lexically normalized: {}",
380                provider.as_str(),
381                quote_os_path(&self.path)
382            );
383        }
384        if self.path != resolved_path {
385            bail!(
386                "external credential path changed for {}; consent covers {}, current path is {}",
387                provider.as_str(),
388                quote_os_path(&self.path),
389                quote_os_path(resolved_path)
390            );
391        }
392        Ok(())
393    }
394
395    /// Validate and mint the read capability consumed by credential adapters.
396    /// No filesystem operation occurs while validating the policy.
397    pub fn read_grant(
398        &self,
399        provider: ProviderKind,
400        source: ExternalCredentialSource,
401        resolved_path: &Path,
402    ) -> Result<ExternalCredentialReadGrant> {
403        self.validate_read_scope(provider, source, resolved_path)?;
404        Ok(ExternalCredentialReadGrant {
405            provider,
406            source,
407            path: resolved_path.to_path_buf(),
408            consent_version: self.consent_version,
409        })
410    }
411}
412
413/// Opaque proof that one exact provider/source/path tuple may be read.
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct ExternalCredentialReadGrant {
416    provider: ProviderKind,
417    source: ExternalCredentialSource,
418    path: PathBuf,
419    consent_version: u32,
420}
421
422impl ExternalCredentialReadGrant {
423    #[must_use]
424    pub fn provider(&self) -> ProviderKind {
425        self.provider
426    }
427
428    #[must_use]
429    pub fn source(&self) -> ExternalCredentialSource {
430        self.source
431    }
432
433    #[must_use]
434    pub fn path(&self) -> &Path {
435        &self.path
436    }
437
438    #[must_use]
439    pub fn consent_version(&self) -> u32 {
440        self.consent_version
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn absolute_test_path(file: &str) -> PathBuf {
449        if cfg!(windows) {
450            PathBuf::from(format!(r"C:\Users\test\{file}"))
451        } else {
452            PathBuf::from(format!("/tmp/{file}"))
453        }
454    }
455
456    #[test]
457    fn disclosed_paths_are_absolute_and_lexically_normalized_without_io() {
458        let resolved =
459            resolve_external_credential_path("one/./two/../auth.json").expect("lexical resolution");
460        assert!(resolved.is_absolute());
461        assert!(
462            resolved.ends_with(Path::new("one/auth.json")),
463            "{}",
464            resolved.display()
465        );
466        assert!(!resolved.to_string_lossy().contains("/./"));
467        assert!(!resolved.to_string_lossy().contains("/../"));
468    }
469
470    #[test]
471    fn structural_status_reports_full_scope_without_io() {
472        let path = absolute_test_path("codex-auth.json");
473        let consent = ExternalCredentialConsentToml::read_only(
474            ProviderKind::OpenaiCodex,
475            ExternalCredentialSource::CodexCli,
476            path.clone(),
477        );
478        let active = external_credential_consent_status(
479            Some(&consent),
480            ProviderKind::OpenaiCodex,
481            ExternalCredentialSource::CodexCli,
482            &path,
483            ProviderKind::OpenaiCodex,
484        );
485        assert_eq!(active.access, ExternalCredentialAccess::ReadOnly);
486        assert_eq!(active.owner, "Codex CLI");
487        assert_eq!(active.path, path);
488        assert_eq!(active.route_state, "active");
489        assert!(active.scope_valid);
490        assert!(active.semantics.contains("no refresh"));
491        assert_eq!(
492            active.revoke_command,
493            "codewhale auth external-revoke --provider openai-codex"
494        );
495
496        let changed_path = absolute_test_path("moved-auth.json");
497        let pinned = external_credential_consent_status(
498            Some(&consent),
499            ProviderKind::OpenaiCodex,
500            ExternalCredentialSource::CodexCli,
501            &changed_path,
502            ProviderKind::OpenaiCodex,
503        );
504        assert!(pinned.scope_valid);
505        assert_eq!(pinned.route_state, "active");
506        assert!(pinned.ambient_path_changed);
507        assert_eq!(pinned.path, path, "report the pinned persisted grant path");
508        let warning = pinned
509            .ambient_path_warning()
510            .expect("ambient mismatch warning");
511        assert!(warning.contains("remains pinned"), "{warning}");
512        assert!(warning.contains(&quote_os_path(&path)), "{warning}");
513    }
514
515    #[test]
516    fn displayed_paths_escape_terminal_and_bidi_controls_on_one_line() {
517        let path = PathBuf::from(
518            "/safe/line\nmanaged\u{1b}[2J\u{2028}first\u{2029}second\u{202e}name.json",
519        );
520        let quoted = quote_os_path(&path);
521        assert!(quoted.starts_with('"') && quoted.ends_with('"'));
522        assert!(quoted.contains("\\n"), "{quoted}");
523        assert!(quoted.contains("\\x1b"), "{quoted}");
524        assert!(quoted.contains("\\u{2028}"), "{quoted}");
525        assert!(quoted.contains("\\u{2029}"), "{quoted}");
526        assert!(quoted.contains("\\u{202e}"), "{quoted}");
527        assert!(!quoted.contains('\n'));
528        assert!(!quoted.contains('\u{1b}'));
529        assert!(!quoted.contains('\u{2028}'));
530        assert!(!quoted.contains('\u{2029}'));
531        assert!(!quoted.contains('\u{202e}'));
532    }
533
534    #[test]
535    fn disabled_disclosure_does_not_imply_normal_provider_network_is_disabled() {
536        let path = absolute_test_path("codex-auth.json");
537        let status = external_credential_consent_status(
538            None,
539            ProviderKind::OpenaiCodex,
540            ExternalCredentialSource::CodexCli,
541            &path,
542            ProviderKind::OpenaiCodex,
543        );
544        assert!(status.semantics.contains("no external-credential"));
545        assert!(status.semantics.contains("normal requests"));
546        assert!(!status.semantics.contains("no network requests"));
547    }
548
549    #[test]
550    fn read_grant_requires_exact_provider_source_path_and_version() {
551        let path = absolute_test_path("codex-auth.json");
552        let consent = ExternalCredentialConsentToml::read_only(
553            ProviderKind::OpenaiCodex,
554            ExternalCredentialSource::CodexCli,
555            path.clone(),
556        );
557
558        let grant = consent
559            .read_grant(
560                ProviderKind::OpenaiCodex,
561                ExternalCredentialSource::CodexCli,
562                &path,
563            )
564            .expect("exact consent tuple");
565        assert_eq!(grant.path(), path);
566
567        assert!(
568            consent
569                .read_grant(ProviderKind::Xai, ExternalCredentialSource::CodexCli, &path)
570                .is_err()
571        );
572        assert!(
573            consent
574                .read_grant(
575                    ProviderKind::OpenaiCodex,
576                    ExternalCredentialSource::GrokCli,
577                    &path
578                )
579                .is_err()
580        );
581        assert!(
582            consent
583                .read_grant(
584                    ProviderKind::OpenaiCodex,
585                    ExternalCredentialSource::CodexCli,
586                    &path.with_file_name("other.json")
587                )
588                .is_err()
589        );
590    }
591
592    #[test]
593    fn persisted_consent_path_must_be_lexically_normalized() {
594        let raw_path = if cfg!(windows) {
595            PathBuf::from(r"C:\Users\test\credentials\..\auth.json")
596        } else {
597            PathBuf::from("/tmp/credentials/../auth.json")
598        };
599        let consent = ExternalCredentialConsentToml::read_only(
600            ProviderKind::Xai,
601            ExternalCredentialSource::GrokCli,
602            raw_path.clone(),
603        );
604        assert!(
605            consent
606                .read_grant(
607                    ProviderKind::Xai,
608                    ExternalCredentialSource::GrokCli,
609                    &raw_path
610                )
611                .is_err()
612        );
613    }
614
615    #[test]
616    fn managed_consent_is_explicitly_unsupported_without_an_adapter() {
617        let path = absolute_test_path("grok-auth.json");
618        let mut consent = ExternalCredentialConsentToml::read_only(
619            ProviderKind::Xai,
620            ExternalCredentialSource::GrokCli,
621            path.clone(),
622        );
623        consent.access = ExternalCredentialAccess::Managed;
624
625        let error = consent
626            .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path)
627            .expect_err("managed access must fail closed");
628        assert!(
629            error
630                .to_string()
631                .contains("schema-safe preservation adapter")
632        );
633    }
634
635    #[test]
636    fn consent_round_trips_every_scope_field() {
637        let path = absolute_test_path("codex-auth.json");
638        let consent = ExternalCredentialConsentToml::read_only(
639            ProviderKind::OpenaiCodex,
640            ExternalCredentialSource::CodexCli,
641            path,
642        );
643
644        let encoded = toml::to_string(&consent).expect("serialize consent");
645        let decoded: ExternalCredentialConsentToml =
646            toml::from_str(&encoded).expect("deserialize consent");
647        assert_eq!(decoded, consent);
648        assert!(encoded.contains("access = \"read_only\""));
649        assert!(encoded.contains("provider = \"openai-codex\""));
650        assert!(encoded.contains("source = \"codex_cli\""));
651        assert!(encoded.contains("consent_version = 1"));
652    }
653
654    #[test]
655    fn disabled_stale_and_relative_consent_fail_before_a_grant() {
656        let path = absolute_test_path("grok-auth.json");
657        let mut consent = ExternalCredentialConsentToml::read_only(
658            ProviderKind::Xai,
659            ExternalCredentialSource::GrokCli,
660            path.clone(),
661        );
662
663        consent.access = ExternalCredentialAccess::Disabled;
664        assert!(
665            consent
666                .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path)
667                .expect_err("disabled consent")
668                .to_string()
669                .contains("disabled")
670        );
671
672        consent.access = ExternalCredentialAccess::ReadOnly;
673        consent.consent_version = EXTERNAL_CREDENTIAL_CONSENT_VERSION + 1;
674        assert!(
675            consent
676                .read_grant(ProviderKind::Xai, ExternalCredentialSource::GrokCli, &path)
677                .expect_err("stale consent")
678                .to_string()
679                .contains("unsupported version")
680        );
681
682        consent.consent_version = EXTERNAL_CREDENTIAL_CONSENT_VERSION;
683        consent.path = PathBuf::from("relative/auth.json");
684        assert!(
685            consent
686                .read_grant(
687                    ProviderKind::Xai,
688                    ExternalCredentialSource::GrokCli,
689                    Path::new("relative/auth.json"),
690                )
691                .expect_err("relative path")
692                .to_string()
693                .contains("must be absolute")
694        );
695    }
696}