返回 CodeWhale
external_credentials.rs
根目录 / crates / config / src / external_credentials.rs
1 use std::path::{Component, Path, PathBuf};
2
3 use anyhow::{Result, bail};
4 use serde::{Deserialize, Serialize};
5
6 use crate::ProviderKind;
7
8 /// Schema version for informed consent to another CLI's credential file.
9 pub const EXTERNAL_CREDENTIAL_CONSENT_VERSION: u32 = 1;
10
11 /// The complete side-effect contract for read-only external credentials.
12 pub 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]
21 pub fn quote_os_path(path: &Path) -> String {
22 quote_os_path_inner(path)
23 }
24
25 #[cfg(unix)]
26 fn 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)]
46 fn 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)))]
60 fn quote_os_path_inner(path: &Path) -> String {
61 quote_path_text(&path.to_string_lossy())
62 }
63
64 #[cfg(not(windows))]
65 fn 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
75 fn 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
90 fn 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).
107 pub 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")]
150 pub 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
160 impl 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")]
174 pub enum ExternalCredentialSource {
175 CodexCli,
176 KimiCodeCli,
177 GrokCli,
178 }
179
180 impl 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)]
203 pub 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
220 impl 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]
238 pub 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)]
302 pub 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
310 impl 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)]
415 pub struct ExternalCredentialReadGrant {
416 provider: ProviderKind,
417 source: ExternalCredentialSource,
418 path: PathBuf,
419 consent_version: u32,
420 }
421
422 impl 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)]
445 mod 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 }
697
697 lines RUST