1use std::collections::{BTreeMap, BTreeSet};
45use std::fmt;
46
47use serde::{Deserialize, Serialize};
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum GrantSource {
53 Env,
55 SecretStore,
57}
58
59impl GrantSource {
60 pub fn as_str(self) -> &'static str {
62 match self {
63 GrantSource::Env => "env",
64 GrantSource::SecretStore => "secret_store",
65 }
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum GrantSourceSpec {
75 Env { var: String },
77 SecretStore { account: String, key: String },
79}
80
81impl GrantSourceSpec {
82 fn kind(&self) -> GrantSource {
83 match self {
84 GrantSourceSpec::Env { .. } => GrantSource::Env,
85 GrantSourceSpec::SecretStore { .. } => GrantSource::SecretStore,
86 }
87 }
88}
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
94pub struct GrantSpec {
95 pub name: String,
97 pub source: GrantSourceSpec,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub expose_as_env: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub for_command: Option<String>,
112}
113
114impl GrantSpec {
115 fn resolve(
120 self,
121 env_lookup: &dyn Fn(&str) -> Option<String>,
122 ) -> Result<SessionGrant, EnvironmentPolicyError> {
123 let name = self.name.trim();
124 if name.is_empty() {
125 return Err(EnvironmentPolicyError::EmptyName);
126 }
127 if let Some(var) = self.expose_as_env.as_deref() {
128 if var.trim().is_empty() {
129 return Err(EnvironmentPolicyError::EmptyExposeVar {
130 name: name.to_string(),
131 });
132 }
133 }
134 let for_command = match self.for_command.as_deref().map(str::trim) {
135 None | Some("") if self.for_command.is_some() => {
136 return Err(EnvironmentPolicyError::EmptyForCommand {
137 name: name.to_string(),
138 });
139 }
140 None => None,
141 Some(command) => {
142 if self
143 .expose_as_env
144 .as_deref()
145 .map(str::trim)
146 .is_none_or(|v| v.is_empty())
147 {
148 return Err(EnvironmentPolicyError::ForWithoutExpose {
149 name: name.to_string(),
150 });
151 }
152 if command.contains('/') || command.contains('\\') {
153 return Err(EnvironmentPolicyError::InvalidForCommand {
154 name: name.to_string(),
155 command: command.to_string(),
156 });
157 }
158 Some(command.to_string())
159 }
160 };
161 let source_kind = self.source.kind();
162 let source_spec = self.source.clone();
163 let resolved_ref = match self.source {
164 GrantSourceSpec::Env { var } => {
165 let var = var.trim();
166 if var.is_empty() {
167 return Err(EnvironmentPolicyError::EmptyEnvVar {
168 name: name.to_string(),
169 });
170 }
171 let value = env_lookup(var).ok_or_else(|| EnvironmentPolicyError::MissingEnv {
172 name: name.to_string(),
173 var: var.to_string(),
174 })?;
175 ResolvedRef::EnvSnapshot(value)
176 }
177 GrantSourceSpec::SecretStore { account, key } => {
178 let (account, key) = (account.trim(), key.trim());
179 if account.is_empty() || key.is_empty() {
180 return Err(EnvironmentPolicyError::EmptySecretRef {
181 name: name.to_string(),
182 });
183 }
184 ResolvedRef::SecretStore {
185 account: account.to_string(),
186 key: key.to_string(),
187 }
188 }
189 };
190 Ok(SessionGrant {
191 name: name.to_string(),
192 source_kind,
193 source_spec,
194 expose_as_env: self.expose_as_env.map(|var| var.trim().to_string()),
195 for_command,
196 resolved_ref,
197 })
198 }
199}
200
201#[derive(Clone, Debug, PartialEq, Eq)]
205enum ResolvedRef {
206 EnvSnapshot(String),
209 SecretStore { account: String, key: String },
213}
214
215#[derive(Clone, Debug, PartialEq, Eq)]
221pub struct SessionGrant {
222 name: String,
223 source_kind: GrantSource,
224 source_spec: GrantSourceSpec,
225 expose_as_env: Option<String>,
226 for_command: Option<String>,
227 resolved_ref: ResolvedRef,
228}
229
230impl SessionGrant {
231 fn matches_spec(&self, spec: &GrantSpec) -> bool {
232 self.name == spec.name.trim()
233 && self.source_spec == spec.source
234 && self.expose_as_env.as_deref() == spec.expose_as_env.as_deref().map(str::trim)
235 && self.for_command.as_deref() == spec.for_command.as_deref().map(str::trim)
236 }
237 pub fn name(&self) -> &str {
239 &self.name
240 }
241
242 pub fn source_kind(&self) -> GrantSource {
244 self.source_kind
245 }
246
247 pub fn exposed_env_var(&self) -> Option<&str> {
249 self.expose_as_env.as_deref()
250 }
251
252 pub fn for_command(&self) -> Option<&str> {
254 self.for_command.as_deref()
255 }
256
257 fn is_session_scoped(&self) -> bool {
260 self.for_command.is_none()
261 }
262
263 fn applies_to_program(&self, program: &str) -> bool {
265 match self.for_command.as_deref() {
266 None => true,
267 Some(expected) => command_basename(program) == expected,
268 }
269 }
270
271 fn exposure(
280 &self,
281 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
282 ) -> Option<Result<(String, String), EnvironmentPolicyError>> {
283 let var = self.expose_as_env.as_ref()?;
284 let value = match &self.resolved_ref {
285 ResolvedRef::EnvSnapshot(value) => value.clone(),
286 ResolvedRef::SecretStore { account, key } => match resolve_secret(account, key) {
287 Some(value) => value,
288 None => {
289 return Some(Err(EnvironmentPolicyError::MissingSecret {
290 name: self.name.clone(),
291 }))
292 }
293 },
294 };
295 Some(Ok((var.clone(), value)))
296 }
297
298 pub fn receipt(&self) -> GrantReceipt {
300 GrantReceipt {
301 name: self.name.clone(),
302 source_kind: self.source_kind.as_str().to_string(),
303 exposed_as_env: self.expose_as_env.clone(),
304 for_command: self.for_command.clone(),
305 }
306 }
307}
308
309pub fn command_basename(program: &str) -> &str {
316 let name = program
317 .rsplit(['/', '\\'])
318 .next()
319 .filter(|name| !name.is_empty())
320 .unwrap_or(program);
321 strip_windows_executable_suffix(name)
322}
323
324fn strip_windows_executable_suffix(name: &str) -> &str {
325 const SUFFIXES: &[&str] = &[".exe", ".bat", ".cmd", ".com"];
326 for suffix in SUFFIXES {
327 let Some(stem_len) = name.len().checked_sub(suffix.len()).filter(|len| *len > 0) else {
328 continue;
329 };
330 if let Some((stem, tail)) = name.split_at_checked(stem_len) {
331 if tail.eq_ignore_ascii_case(suffix) {
332 return stem;
333 }
334 }
335 }
336 name
337}
338
339#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(rename_all = "snake_case")]
343pub enum EnvironmentPolicyKind {
344 #[default]
346 Inherited,
347 Isolated,
349 Granted,
351}
352
353impl EnvironmentPolicyKind {
354 pub fn as_str(self) -> &'static str {
355 match self {
356 EnvironmentPolicyKind::Inherited => "inherited",
357 EnvironmentPolicyKind::Isolated => "isolated",
358 EnvironmentPolicyKind::Granted => "granted",
359 }
360 }
361}
362
363#[derive(Clone, Debug, PartialEq, Eq)]
368pub struct SessionEnvironment {
369 kind: EnvironmentPolicyKind,
370 launcher_snapshot: BTreeMap<String, String>,
371 grants: Vec<SessionGrant>,
372}
373
374impl SessionEnvironment {
375 pub fn inherited() -> Self {
377 Self::launch(EnvironmentPolicyKind::Inherited, Vec::new(), &|name| {
378 std::env::var(name).ok()
379 })
380 .expect("the inherited policy has no fallible grant configuration")
381 }
382
383 pub fn launch(
390 kind: EnvironmentPolicyKind,
391 specs: Vec<GrantSpec>,
392 env_lookup: &dyn Fn(&str) -> Option<String>,
393 ) -> Result<Self, EnvironmentPolicyError> {
394 let mut launcher_snapshot = capture_process_environment();
395 for name in super::environment_policy::ENV_ALLOWLIST {
396 if let Some(value) = env_lookup(name) {
397 launcher_snapshot.insert((*name).to_string(), value);
398 }
399 }
400 Self::launch_from_snapshot(kind, specs, launcher_snapshot, env_lookup)
401 }
402
403 pub fn launch_from_snapshot(
409 kind: EnvironmentPolicyKind,
410 specs: Vec<GrantSpec>,
411 launcher_snapshot: BTreeMap<String, String>,
412 env_lookup: &dyn Fn(&str) -> Option<String>,
413 ) -> Result<Self, EnvironmentPolicyError> {
414 if !matches!(kind, EnvironmentPolicyKind::Granted) && !specs.is_empty() {
415 return Err(EnvironmentPolicyError::PolicyForbidsGrants {
416 policy: kind,
417 attempted: specs.len(),
418 });
419 }
420 validate_unique_specs(&specs)?;
421 let grants = specs
422 .into_iter()
423 .map(|spec| spec.resolve(env_lookup))
424 .collect::<Result<Vec<_>, _>>()?;
425 let launcher_snapshot = if matches!(kind, EnvironmentPolicyKind::Inherited) {
426 launcher_snapshot
427 } else {
428 launcher_snapshot
429 .into_iter()
430 .filter(|(name, _)| {
431 super::environment_policy::ENV_ALLOWLIST.contains(&name.as_str())
432 })
433 .collect()
434 };
435 Ok(SessionEnvironment {
436 kind,
437 launcher_snapshot,
438 grants,
439 })
440 }
441
442 pub fn isolated() -> Self {
444 Self::launch(EnvironmentPolicyKind::Isolated, Vec::new(), &|name| {
445 std::env::var(name).ok()
446 })
447 .expect("the isolated policy has no fallible grant configuration")
448 }
449
450 pub fn kind(&self) -> EnvironmentPolicyKind {
451 self.kind
452 }
453
454 pub fn is_isolated(&self) -> bool {
455 matches!(self.kind, EnvironmentPolicyKind::Isolated)
456 }
457
458 pub fn allows_implicit_discovery(&self) -> bool {
462 matches!(self.kind, EnvironmentPolicyKind::Inherited)
463 }
464
465 pub fn narrow(
468 &self,
469 requested: EnvironmentPolicyKind,
470 specs: Vec<GrantSpec>,
471 ) -> Result<Self, EnvironmentPolicyError> {
472 match (self.kind, requested) {
473 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Inherited)
474 if specs.is_empty() =>
475 {
476 Ok(self.clone())
477 }
478 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Isolated)
479 if specs.is_empty() =>
480 {
481 Ok(Self {
482 kind: requested,
483 launcher_snapshot: self.launcher_snapshot.clone(),
484 grants: Vec::new(),
485 })
486 }
487 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Granted) => {
488 if let Some(spec) = specs
489 .iter()
490 .find(|spec| matches!(spec.source, GrantSourceSpec::SecretStore { .. }))
491 {
492 return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
493 parent: self.kind,
494 requested,
495 offending_grant: Some(spec.name.trim().to_string()),
496 detail: "an inherited parent can grant only values in its launch-time environment snapshot; secret-store authority must be granted to the parent first".to_string(),
497 });
498 }
499 let snapshot = self.launcher_snapshot.clone();
500 Self::launch_from_snapshot(requested, specs, snapshot.clone(), &|name| {
501 snapshot.get(name).cloned()
502 })
503 }
504 (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Isolated)
505 if specs.is_empty() =>
506 {
507 Ok(Self {
508 kind: requested,
509 launcher_snapshot: self.launcher_snapshot.clone(),
510 grants: Vec::new(),
511 })
512 }
513 (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Granted) => {
514 validate_unique_specs(&specs)?;
515 let mut grants = Vec::with_capacity(specs.len());
516 for spec in &specs {
517 let Some(grant) = self.grants.iter().find(|grant| grant.matches_spec(spec))
518 else {
519 return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
520 parent: self.kind,
521 requested,
522 offending_grant: Some(spec.name.trim().to_string()),
523 detail: format!(
524 "grant '{}' is not an unchanged subset of the parent grants",
525 spec.name.trim()
526 ),
527 });
528 };
529 grants.push(grant.clone());
530 }
531 Ok(Self {
532 kind: requested,
533 launcher_snapshot: self.launcher_snapshot.clone(),
534 grants,
535 })
536 }
537 _ => Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
538 parent: self.kind,
539 requested,
540 offending_grant: specs.first().map(|spec| spec.name.trim().to_string()),
541 detail:
542 "a child may keep or reduce its parent's environment access, never widen it"
543 .to_string(),
544 }),
545 }
546 }
547
548 pub(crate) fn launcher_value(&self, name: &str) -> Option<&str> {
549 self.launcher_snapshot.get(name).map(String::as_str)
550 }
551
552 pub(crate) fn launcher_snapshot(&self) -> &BTreeMap<String, String> {
553 &self.launcher_snapshot
554 }
555
556 pub fn grants(&self) -> &[SessionGrant] {
558 &self.grants
559 }
560
561 pub fn receipts(&self) -> Vec<GrantReceipt> {
564 self.grants.iter().map(SessionGrant::receipt).collect()
565 }
566
567 pub fn env_exposure(
579 &self,
580 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
581 ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
582 self.grants
583 .iter()
584 .filter(|grant| grant.is_session_scoped())
585 .filter_map(|grant| grant.exposure(resolve_secret))
586 .collect()
587 }
588
589 pub fn env_exposure_for_command(
593 &self,
594 program: &str,
595 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
596 ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
597 self.grants
598 .iter()
599 .filter(|grant| grant.applies_to_program(program))
600 .filter_map(|grant| grant.exposure(resolve_secret))
601 .collect()
602 }
603
604 pub fn env_exposure_for(
616 &self,
617 var: &str,
618 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
619 ) -> Result<Option<String>, EnvironmentPolicyError> {
620 let Some(grant) = self
621 .grants
622 .iter()
623 .find(|grant| grant.is_session_scoped() && grant.expose_as_env.as_deref() == Some(var))
624 else {
625 return Ok(None);
626 };
627 grant
628 .exposure(resolve_secret)
629 .transpose()
630 .map(|pair| pair.map(|(_, value)| value))
631 }
632}
633
634fn capture_process_environment() -> BTreeMap<String, String> {
635 std::env::vars_os()
636 .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?)))
637 .collect()
638}
639
640fn validate_unique_specs(specs: &[GrantSpec]) -> Result<(), EnvironmentPolicyError> {
641 let mut names = BTreeSet::new();
642 let mut targets = BTreeSet::new();
643 for spec in specs {
644 let name = spec.name.trim();
645 if !name.is_empty() && !names.insert(name) {
646 return Err(EnvironmentPolicyError::DuplicateGrant {
647 name: name.to_string(),
648 });
649 }
650 if let Some(target) = spec.expose_as_env.as_deref().map(str::trim) {
651 if !target.is_empty() && !targets.insert(target) {
652 return Err(EnvironmentPolicyError::DuplicateExposureTarget {
653 target: target.to_string(),
654 });
655 }
656 }
657 }
658 Ok(())
659}
660
661#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
667pub struct GrantReceipt {
668 pub name: String,
669 pub source_kind: String,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub exposed_as_env: Option<String>,
672 #[serde(default, skip_serializing_if = "Option::is_none")]
673 pub for_command: Option<String>,
674}
675
676#[derive(Clone, Debug, PartialEq, Eq)]
679pub enum EnvironmentPolicyError {
680 EmptyName,
682 EmptyEnvVar { name: String },
684 EmptySecretRef { name: String },
686 EmptyExposeVar { name: String },
688 EmptyForCommand { name: String },
690 ForWithoutExpose { name: String },
692 InvalidForCommand { name: String, command: String },
695 MissingEnv { name: String, var: String },
697 PolicyForbidsGrants {
699 policy: EnvironmentPolicyKind,
700 attempted: usize,
701 },
702 DuplicateGrant { name: String },
704 DuplicateExposureTarget { target: String },
706 ChildPolicyExceedsParent {
708 parent: EnvironmentPolicyKind,
709 requested: EnvironmentPolicyKind,
710 offending_grant: Option<String>,
711 detail: String,
712 },
713 MissingSecret { name: String },
715}
716
717impl fmt::Display for EnvironmentPolicyError {
718 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
719 match self {
720 EnvironmentPolicyError::EmptyName => write!(
721 f,
722 "[environment_policy.empty_grant_name] grant spec has an empty name"
723 ),
724 EnvironmentPolicyError::EmptyEnvVar { name } => {
725 write!(
726 f,
727 "[environment_policy.empty_source_variable] grant '{name}' env source names an empty variable"
728 )
729 }
730 EnvironmentPolicyError::EmptySecretRef { name } => {
731 write!(
732 f,
733 "[environment_policy.empty_secret_reference] grant '{name}' secret source names an empty account/key"
734 )
735 }
736 EnvironmentPolicyError::EmptyExposeVar { name } => {
737 write!(
738 f,
739 "[environment_policy.empty_exposure_target] grant '{name}' expose target is an empty variable"
740 )
741 }
742 EnvironmentPolicyError::EmptyForCommand { name } => {
743 write!(
744 f,
745 "[environment_policy.empty_for_command] grant '{name}' for_command binding is empty; name the command basename (for example 'gh')"
746 )
747 }
748 EnvironmentPolicyError::ForWithoutExpose { name } => {
749 write!(
750 f,
751 "[environment_policy.for_without_expose] grant '{name}' declares for_command without expose_as_env; command binding only applies to an exposed environment variable"
752 )
753 }
754 EnvironmentPolicyError::InvalidForCommand { name, command } => {
755 write!(
756 f,
757 "[environment_policy.invalid_for_command] grant '{name}' for_command '{command}' must be a command basename, not a path"
758 )
759 }
760 EnvironmentPolicyError::MissingEnv { name, var } => write!(
761 f,
762 "[environment_policy.source_variable_missing] grant '{name}' env source variable '{var}' is not set in the launcher environment; set it before launch or choose another source"
763 ),
764 EnvironmentPolicyError::PolicyForbidsGrants { policy, attempted } => write!(
765 f,
766 "[environment_policy.grants_forbidden] environment policy '{}' forbids grants, but {attempted} were declared; use 'granted' or remove the grants",
767 policy.as_str()
768 ),
769 EnvironmentPolicyError::DuplicateGrant { name } => write!(
770 f,
771 "[environment_policy.duplicate_grant] grant name '{name}' is declared more than once; give every grant a unique name"
772 ),
773 EnvironmentPolicyError::DuplicateExposureTarget { target } => write!(
774 f,
775 "[environment_policy.duplicate_exposure_target] environment target '{target}' is exposed by more than one grant; choose one grant for each target"
776 ),
777 EnvironmentPolicyError::ChildPolicyExceedsParent {
778 parent,
779 requested,
780 offending_grant: _,
781 detail,
782 } => write!(
783 f,
784 "[environment_policy.child_exceeds_parent] child policy '{}' exceeds parent policy '{}': {detail}",
785 requested.as_str(),
786 parent.as_str()
787 ),
788 EnvironmentPolicyError::MissingSecret { name } => {
789 write!(
790 f,
791 "[environment_policy.secret_unavailable] grant '{name}' is unavailable from the secret store; restore access, rotate the reference, or remove the grant"
792 )
793 }
794 }
795 }
796}
797
798impl std::error::Error for EnvironmentPolicyError {}
799
800impl EnvironmentPolicyError {
801 pub fn code(&self) -> &'static str {
803 match self {
804 Self::EmptyName => "environment_policy.empty_grant_name",
805 Self::EmptyEnvVar { .. } => "environment_policy.empty_source_variable",
806 Self::EmptySecretRef { .. } => "environment_policy.empty_secret_reference",
807 Self::EmptyExposeVar { .. } => "environment_policy.empty_exposure_target",
808 Self::EmptyForCommand { .. } => "environment_policy.empty_for_command",
809 Self::ForWithoutExpose { .. } => "environment_policy.for_without_expose",
810 Self::InvalidForCommand { .. } => "environment_policy.invalid_for_command",
811 Self::MissingEnv { .. } => "environment_policy.source_variable_missing",
812 Self::PolicyForbidsGrants { .. } => "environment_policy.grants_forbidden",
813 Self::DuplicateGrant { .. } => "environment_policy.duplicate_grant",
814 Self::DuplicateExposureTarget { .. } => "environment_policy.duplicate_exposure_target",
815 Self::ChildPolicyExceedsParent { .. } => "environment_policy.child_exceeds_parent",
816 Self::MissingSecret { .. } => "environment_policy.secret_unavailable",
817 }
818 }
819
820 pub fn to_json(&self) -> serde_json::Value {
822 let mut value = serde_json::json!({
823 "code": self.code(),
824 "message": self.to_string(),
825 });
826 let object = value
827 .as_object_mut()
828 .expect("environment policy diagnostic is an object");
829 match self {
830 Self::EmptyEnvVar { name }
831 | Self::EmptySecretRef { name }
832 | Self::EmptyExposeVar { name }
833 | Self::EmptyForCommand { name }
834 | Self::ForWithoutExpose { name }
835 | Self::MissingSecret { name } => {
836 object.insert("grant".to_string(), serde_json::json!(name));
837 }
838 Self::InvalidForCommand { name, command } => {
839 object.insert("grant".to_string(), serde_json::json!(name));
840 object.insert("forCommand".to_string(), serde_json::json!(command));
841 }
842 Self::MissingEnv { name, var } => {
843 object.insert("grant".to_string(), serde_json::json!(name));
844 object.insert("sourceVariable".to_string(), serde_json::json!(var));
845 }
846 Self::PolicyForbidsGrants { policy, attempted } => {
847 object.insert("policy".to_string(), serde_json::json!(policy.as_str()));
848 object.insert("attemptedGrants".to_string(), serde_json::json!(attempted));
849 }
850 Self::DuplicateGrant { name } => {
851 object.insert("grant".to_string(), serde_json::json!(name));
852 }
853 Self::DuplicateExposureTarget { target } => {
854 object.insert("target".to_string(), serde_json::json!(target));
855 }
856 Self::ChildPolicyExceedsParent {
857 parent,
858 requested,
859 offending_grant,
860 detail,
861 } => {
862 object.insert(
863 "parentPolicy".to_string(),
864 serde_json::json!(parent.as_str()),
865 );
866 object.insert(
867 "requestedPolicy".to_string(),
868 serde_json::json!(requested.as_str()),
869 );
870 object.insert("detail".to_string(), serde_json::json!(detail));
871 if let Some(grant) = offending_grant {
872 object.insert("grant".to_string(), serde_json::json!(grant));
873 }
874 }
875 Self::EmptyName => {}
876 }
877 value
878 }
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884
885 fn no_env(_: &str) -> Option<String> {
886 None
887 }
888
889 fn env_from(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
890 move |var: &str| {
891 pairs
892 .iter()
893 .find(|(name, _)| *name == var)
894 .map(|(_, value)| value.to_string())
895 }
896 }
897
898 fn env_grant(name: &str, var: &str, expose: Option<&str>) -> GrantSpec {
899 GrantSpec {
900 name: name.to_string(),
901 source: GrantSourceSpec::Env {
902 var: var.to_string(),
903 },
904 expose_as_env: expose.map(str::to_string),
905 for_command: None,
906 }
907 }
908
909 fn secret_grant(name: &str, account: &str, key: &str, expose: Option<&str>) -> GrantSpec {
910 GrantSpec {
911 name: name.to_string(),
912 source: GrantSourceSpec::SecretStore {
913 account: account.to_string(),
914 key: key.to_string(),
915 },
916 expose_as_env: expose.map(str::to_string),
917 for_command: None,
918 }
919 }
920
921 fn command_grant(
922 name: &str,
923 account: &str,
924 key: &str,
925 expose: &str,
926 for_command: &str,
927 ) -> GrantSpec {
928 GrantSpec {
929 name: name.to_string(),
930 source: GrantSourceSpec::SecretStore {
931 account: account.to_string(),
932 key: key.to_string(),
933 },
934 expose_as_env: Some(expose.to_string()),
935 for_command: Some(for_command.to_string()),
936 }
937 }
938
939 #[test]
940 fn isolated_rejects_any_grant_at_launch() {
941 let specs = vec![secret_grant("gh_token", "gh", "token", None)];
942 let err = SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, specs, &no_env)
943 .expect_err("isolated must reject grants");
944 assert_eq!(
945 err,
946 EnvironmentPolicyError::PolicyForbidsGrants {
947 policy: EnvironmentPolicyKind::Isolated,
948 attempted: 1
949 }
950 );
951
952 let environment =
955 SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, vec![], &no_env).unwrap();
956 assert!(environment.is_isolated());
957 assert!(environment.grants().is_empty());
958 assert!(environment.receipts().is_empty());
959 assert!(SessionEnvironment::isolated().grants().is_empty());
960 assert_eq!(
961 EnvironmentPolicyKind::default(),
962 EnvironmentPolicyKind::Inherited
963 );
964 }
965
966 #[test]
967 fn granted_policy_resolves_once_into_typed_record() {
968 let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
969 let specs = vec![
970 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
971 secret_grant("gh_token", "gh", "token", Some("GH_TOKEN")),
972 ];
973 let environment =
974 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
975
976 let grants = environment.grants();
977 assert_eq!(grants.len(), 2);
978 assert_eq!(grants[0].name(), "fireworks");
980 assert_eq!(grants[0].source_kind(), GrantSource::Env);
981 assert_eq!(grants[0].exposed_env_var(), Some("FIREWORKS_API_KEY"));
982 assert_eq!(grants[1].name(), "gh_token");
983 assert_eq!(grants[1].source_kind(), GrantSource::SecretStore);
984 assert_eq!(grants[1].exposed_env_var(), Some("GH_TOKEN"));
985
986 let resolve_secret = |account: &str, key: &str| -> Option<String> {
989 (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
990 };
991 let mut pairs = environment.env_exposure(&resolve_secret).unwrap();
992 pairs.sort();
993 assert_eq!(
994 pairs,
995 vec![
996 (
997 "FIREWORKS_API_KEY".to_string(),
998 "fw-secret-value".to_string()
999 ),
1000 ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
1001 ]
1002 );
1003 }
1004
1005 #[test]
1006 fn secret_pointer_is_not_resolved_at_launch() {
1007 let specs = vec![secret_grant("gh_token", "gh", "token", None)];
1011 let environment =
1012 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env).unwrap();
1013 let never = |_: &str, _: &str| -> Option<String> {
1014 panic!("secret resolver must not run for an unexposed grant")
1015 };
1016 assert!(environment.env_exposure(&never).unwrap().is_empty());
1017 }
1018
1019 #[test]
1020 fn env_grant_snapshots_value_at_launch() {
1021 let at_launch = env_from(&[("TOKEN", "live-at-launch")]);
1024 let specs = vec![env_grant("t", "TOKEN", Some("TOKEN"))];
1025 let environment =
1026 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &at_launch).unwrap();
1027
1028 let never_secret = |_: &str, _: &str| -> Option<String> { None };
1029 let pairs = environment.env_exposure(&never_secret).unwrap();
1030 assert_eq!(
1031 pairs,
1032 vec![("TOKEN".to_string(), "live-at-launch".to_string())]
1033 );
1034 assert_eq!(
1037 environment.env_exposure(&never_secret).unwrap(),
1038 vec![("TOKEN".to_string(), "live-at-launch".to_string())]
1039 );
1040 }
1041
1042 #[test]
1043 fn restricted_policies_do_not_retain_unrelated_launcher_values() {
1044 let snapshot = BTreeMap::from([
1045 ("PATH".to_string(), "/bin".to_string()),
1046 (
1047 "UNRELATED_SECRET".to_string(),
1048 "must-not-be-retained".to_string(),
1049 ),
1050 ]);
1051 let granted = SessionEnvironment::launch_from_snapshot(
1052 EnvironmentPolicyKind::Granted,
1053 Vec::new(),
1054 snapshot,
1055 &no_env,
1056 )
1057 .unwrap();
1058 assert_eq!(granted.launcher_value("PATH"), Some("/bin"));
1059 assert_eq!(granted.launcher_value("UNRELATED_SECRET"), None);
1060 }
1061
1062 #[test]
1063 fn receipts_record_shape_and_never_the_value() {
1064 let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
1065 let specs = vec![
1066 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1067 secret_grant("gh_token", "gh", "token", None),
1068 ];
1069 let environment =
1070 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
1071
1072 let receipts = environment.receipts();
1073 assert_eq!(
1074 receipts,
1075 vec![
1076 GrantReceipt {
1077 name: "fireworks".to_string(),
1078 source_kind: "env".to_string(),
1079 exposed_as_env: Some("FIREWORKS_API_KEY".to_string()),
1080 for_command: None,
1081 },
1082 GrantReceipt {
1083 name: "gh_token".to_string(),
1084 source_kind: "secret_store".to_string(),
1085 exposed_as_env: None,
1086 for_command: None,
1087 },
1088 ]
1089 );
1090
1091 let json = serde_json::to_string(&receipts).unwrap();
1095 assert!(
1096 !json.contains("fw-secret-value"),
1097 "receipt leaked env value"
1098 );
1099 assert!(!json.contains("gh/token"), "receipt leaked secret pointer");
1100 assert!(json.contains("\"source_kind\":\"env\""));
1101 assert!(json.contains("\"source_kind\":\"secret_store\""));
1102 }
1103
1104 #[test]
1105 fn grant_spec_is_value_free_over_the_wire() {
1106 let spec = env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY"));
1109 let json = serde_json::to_string(&spec).unwrap();
1110 let round: GrantSpec = serde_json::from_str(&json).unwrap();
1111 assert_eq!(round, spec);
1112 assert!(json.contains("\"env\""));
1113 assert!(json.contains("FIREWORKS_API_KEY"));
1114
1115 assert_eq!(
1117 serde_json::from_str::<EnvironmentPolicyKind>("\"granted\"").unwrap(),
1118 EnvironmentPolicyKind::Granted
1119 );
1120 assert_eq!(
1121 EnvironmentPolicyKind::default(),
1122 EnvironmentPolicyKind::Inherited
1123 );
1124 }
1125
1126 #[test]
1127 fn missing_env_source_fails_at_launch() {
1128 let specs = vec![env_grant("t", "ABSENT_VAR", None)];
1129 let err = SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env)
1130 .expect_err("absent env var must fail resolution");
1131 assert_eq!(
1132 err,
1133 EnvironmentPolicyError::MissingEnv {
1134 name: "t".to_string(),
1135 var: "ABSENT_VAR".to_string(),
1136 }
1137 );
1138 }
1139
1140 #[test]
1141 fn resolve_rejects_empty_fields() {
1142 let env = env_from(&[("X", "v")]);
1143 assert_eq!(
1144 SessionEnvironment::launch(
1145 EnvironmentPolicyKind::Granted,
1146 vec![env_grant("", "X", None)],
1147 &env
1148 ),
1149 Err(EnvironmentPolicyError::EmptyName)
1150 );
1151 assert_eq!(
1152 SessionEnvironment::launch(
1153 EnvironmentPolicyKind::Granted,
1154 vec![env_grant("t", "", None)],
1155 &env
1156 ),
1157 Err(EnvironmentPolicyError::EmptyEnvVar {
1158 name: "t".to_string()
1159 })
1160 );
1161 assert_eq!(
1162 SessionEnvironment::launch(
1163 EnvironmentPolicyKind::Granted,
1164 vec![secret_grant("t", "acct", "", None)],
1165 &env
1166 ),
1167 Err(EnvironmentPolicyError::EmptySecretRef {
1168 name: "t".to_string()
1169 })
1170 );
1171 assert_eq!(
1172 SessionEnvironment::launch(
1173 EnvironmentPolicyKind::Granted,
1174 vec![env_grant("t", "X", Some(" "))],
1175 &env
1176 ),
1177 Err(EnvironmentPolicyError::EmptyExposeVar {
1178 name: "t".to_string()
1179 })
1180 );
1181 }
1182
1183 #[test]
1184 fn duplicate_names_and_targets_fail_with_stable_codes() {
1185 let env = env_from(&[("A", "a"), ("B", "b")]);
1186 let duplicate_name = SessionEnvironment::launch(
1187 EnvironmentPolicyKind::Granted,
1188 vec![
1189 env_grant("token", "A", Some("A")),
1190 env_grant("token", "B", Some("B")),
1191 ],
1192 &env,
1193 )
1194 .unwrap_err();
1195 assert_eq!(duplicate_name.code(), "environment_policy.duplicate_grant");
1196
1197 let duplicate_target = SessionEnvironment::launch(
1198 EnvironmentPolicyKind::Granted,
1199 vec![
1200 env_grant("a", "A", Some("TOKEN")),
1201 env_grant("b", "B", Some("TOKEN")),
1202 ],
1203 &env,
1204 )
1205 .unwrap_err();
1206 assert_eq!(
1207 duplicate_target.code(),
1208 "environment_policy.duplicate_exposure_target"
1209 );
1210 }
1211
1212 #[test]
1213 fn child_policy_can_only_narrow_parent_authority() {
1214 let snapshot = BTreeMap::from([
1215 ("TOKEN".to_string(), "parent-value".to_string()),
1216 ("PATH".to_string(), "/bin".to_string()),
1217 ]);
1218 let parent = SessionEnvironment::launch_from_snapshot(
1219 EnvironmentPolicyKind::Inherited,
1220 Vec::new(),
1221 snapshot.clone(),
1222 &|name| snapshot.get(name).cloned(),
1223 )
1224 .unwrap();
1225 let child = parent
1226 .narrow(
1227 EnvironmentPolicyKind::Granted,
1228 vec![env_grant("token", "TOKEN", Some("TOKEN"))],
1229 )
1230 .unwrap();
1231 assert_eq!(child.kind(), EnvironmentPolicyKind::Granted);
1232 assert_eq!(child.grants().len(), 1);
1233
1234 let error = child
1235 .narrow(EnvironmentPolicyKind::Inherited, Vec::new())
1236 .unwrap_err();
1237 assert_eq!(error.code(), "environment_policy.child_exceeds_parent");
1238 assert_eq!(error.to_json()["parentPolicy"], "granted");
1239 assert_eq!(error.to_json()["requestedPolicy"], "inherited");
1240
1241 let error = child
1242 .narrow(
1243 EnvironmentPolicyKind::Granted,
1244 vec![env_grant("other", "OTHER_TOKEN", Some("OTHER_TOKEN"))],
1245 )
1246 .unwrap_err();
1247 let diagnostic = error.to_json();
1248 assert_eq!(
1249 diagnostic["code"],
1250 "environment_policy.child_exceeds_parent"
1251 );
1252 assert_eq!(diagnostic["parentPolicy"], "granted");
1253 assert_eq!(diagnostic["requestedPolicy"], "granted");
1254 assert_eq!(diagnostic["grant"], "other");
1255 assert!(diagnostic["message"]
1256 .as_str()
1257 .unwrap()
1258 .contains("unchanged subset of the parent grants"));
1259 }
1260
1261 #[test]
1262 fn command_bound_grant_is_absent_from_session_exposure() {
1263 let resolve_secret = |account: &str, key: &str| -> Option<String> {
1264 (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
1265 };
1266 let environment = SessionEnvironment::launch(
1267 EnvironmentPolicyKind::Granted,
1268 vec![
1269 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1270 command_grant("gh_token", "gh", "token", "GH_TOKEN", "gh"),
1271 ],
1272 &env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]),
1273 )
1274 .unwrap();
1275
1276 let ambient = environment.env_exposure(&resolve_secret).unwrap();
1278 assert_eq!(
1279 ambient,
1280 vec![(
1281 "FIREWORKS_API_KEY".to_string(),
1282 "fw-secret-value".to_string()
1283 )]
1284 );
1285 assert_eq!(
1286 environment
1287 .env_exposure_for("GH_TOKEN", &resolve_secret)
1288 .unwrap(),
1289 None
1290 );
1291
1292 let mut for_gh = environment
1294 .env_exposure_for_command("gh", &resolve_secret)
1295 .unwrap();
1296 for_gh.sort();
1297 assert_eq!(
1298 for_gh,
1299 vec![
1300 (
1301 "FIREWORKS_API_KEY".to_string(),
1302 "fw-secret-value".to_string()
1303 ),
1304 ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
1305 ]
1306 );
1307 let for_git = environment
1308 .env_exposure_for_command("/usr/bin/git", &resolve_secret)
1309 .unwrap();
1310 assert_eq!(
1311 for_git,
1312 vec![(
1313 "FIREWORKS_API_KEY".to_string(),
1314 "fw-secret-value".to_string()
1315 )]
1316 );
1317 assert!(environment
1318 .env_exposure_for_command("/usr/local/bin/gh", &resolve_secret)
1319 .unwrap()
1320 .into_iter()
1321 .any(|(var, _)| var == "GH_TOKEN"));
1322 assert_eq!(command_basename("C:\\Tools\\gh.exe"), "gh");
1323
1324 let receipts = environment.receipts();
1325 assert_eq!(receipts[1].for_command.as_deref(), Some("gh"));
1326 assert_eq!(receipts[1].exposed_as_env.as_deref(), Some("GH_TOKEN"));
1327 }
1328
1329 #[test]
1330 fn for_command_requires_expose_and_rejects_paths() {
1331 let err = SessionEnvironment::launch(
1332 EnvironmentPolicyKind::Granted,
1333 vec![GrantSpec {
1334 name: "gh_token".to_string(),
1335 source: GrantSourceSpec::SecretStore {
1336 account: "gh".to_string(),
1337 key: "token".to_string(),
1338 },
1339 expose_as_env: None,
1340 for_command: Some("gh".to_string()),
1341 }],
1342 &no_env,
1343 )
1344 .unwrap_err();
1345 assert_eq!(err.code(), "environment_policy.for_without_expose");
1346
1347 let err = SessionEnvironment::launch(
1348 EnvironmentPolicyKind::Granted,
1349 vec![GrantSpec {
1350 name: "gh_token".to_string(),
1351 source: GrantSourceSpec::SecretStore {
1352 account: "gh".to_string(),
1353 key: "token".to_string(),
1354 },
1355 expose_as_env: Some("GH_TOKEN".to_string()),
1356 for_command: Some("/usr/bin/gh".to_string()),
1357 }],
1358 &no_env,
1359 )
1360 .unwrap_err();
1361 assert_eq!(err.code(), "environment_policy.invalid_for_command");
1362 }
1363}