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