| 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("e_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 |