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}
105
106impl GrantSpec {
107 fn resolve(
112 self,
113 env_lookup: &dyn Fn(&str) -> Option<String>,
114 ) -> Result<SessionGrant, EnvironmentPolicyError> {
115 let name = self.name.trim();
116 if name.is_empty() {
117 return Err(EnvironmentPolicyError::EmptyName);
118 }
119 if let Some(var) = self.expose_as_env.as_deref() {
120 if var.trim().is_empty() {
121 return Err(EnvironmentPolicyError::EmptyExposeVar {
122 name: name.to_string(),
123 });
124 }
125 }
126 let source_kind = self.source.kind();
127 let source_spec = self.source.clone();
128 let resolved_ref = match self.source {
129 GrantSourceSpec::Env { var } => {
130 let var = var.trim();
131 if var.is_empty() {
132 return Err(EnvironmentPolicyError::EmptyEnvVar {
133 name: name.to_string(),
134 });
135 }
136 let value = env_lookup(var).ok_or_else(|| EnvironmentPolicyError::MissingEnv {
137 name: name.to_string(),
138 var: var.to_string(),
139 })?;
140 ResolvedRef::EnvSnapshot(value)
141 }
142 GrantSourceSpec::SecretStore { account, key } => {
143 let (account, key) = (account.trim(), key.trim());
144 if account.is_empty() || key.is_empty() {
145 return Err(EnvironmentPolicyError::EmptySecretRef {
146 name: name.to_string(),
147 });
148 }
149 ResolvedRef::SecretStore {
150 account: account.to_string(),
151 key: key.to_string(),
152 }
153 }
154 };
155 Ok(SessionGrant {
156 name: name.to_string(),
157 source_kind,
158 source_spec,
159 expose_as_env: self.expose_as_env.map(|var| var.trim().to_string()),
160 resolved_ref,
161 })
162 }
163}
164
165#[derive(Clone, Debug, PartialEq, Eq)]
169enum ResolvedRef {
170 EnvSnapshot(String),
173 SecretStore { account: String, key: String },
177}
178
179#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct SessionGrant {
186 name: String,
187 source_kind: GrantSource,
188 source_spec: GrantSourceSpec,
189 expose_as_env: Option<String>,
190 resolved_ref: ResolvedRef,
191}
192
193impl SessionGrant {
194 fn matches_spec(&self, spec: &GrantSpec) -> bool {
195 self.name == spec.name.trim()
196 && self.source_spec == spec.source
197 && self.expose_as_env.as_deref() == spec.expose_as_env.as_deref().map(str::trim)
198 }
199 pub fn name(&self) -> &str {
201 &self.name
202 }
203
204 pub fn source_kind(&self) -> GrantSource {
206 self.source_kind
207 }
208
209 pub fn exposed_env_var(&self) -> Option<&str> {
211 self.expose_as_env.as_deref()
212 }
213
214 fn exposure(
223 &self,
224 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
225 ) -> Option<Result<(String, String), EnvironmentPolicyError>> {
226 let var = self.expose_as_env.as_ref()?;
227 let value = match &self.resolved_ref {
228 ResolvedRef::EnvSnapshot(value) => value.clone(),
229 ResolvedRef::SecretStore { account, key } => match resolve_secret(account, key) {
230 Some(value) => value,
231 None => {
232 return Some(Err(EnvironmentPolicyError::MissingSecret {
233 name: self.name.clone(),
234 }))
235 }
236 },
237 };
238 Some(Ok((var.clone(), value)))
239 }
240
241 pub fn receipt(&self) -> GrantReceipt {
243 GrantReceipt {
244 name: self.name.clone(),
245 source_kind: self.source_kind.as_str().to_string(),
246 exposed_as_env: self.expose_as_env.clone(),
247 }
248 }
249}
250
251#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum EnvironmentPolicyKind {
256 #[default]
258 Inherited,
259 Isolated,
261 Granted,
263}
264
265impl EnvironmentPolicyKind {
266 pub fn as_str(self) -> &'static str {
267 match self {
268 EnvironmentPolicyKind::Inherited => "inherited",
269 EnvironmentPolicyKind::Isolated => "isolated",
270 EnvironmentPolicyKind::Granted => "granted",
271 }
272 }
273}
274
275#[derive(Clone, Debug, PartialEq, Eq)]
280pub struct SessionEnvironment {
281 kind: EnvironmentPolicyKind,
282 launcher_snapshot: BTreeMap<String, String>,
283 grants: Vec<SessionGrant>,
284}
285
286impl SessionEnvironment {
287 pub fn inherited() -> Self {
289 Self::launch(EnvironmentPolicyKind::Inherited, Vec::new(), &|name| {
290 std::env::var(name).ok()
291 })
292 .expect("the inherited policy has no fallible grant configuration")
293 }
294
295 pub fn launch(
302 kind: EnvironmentPolicyKind,
303 specs: Vec<GrantSpec>,
304 env_lookup: &dyn Fn(&str) -> Option<String>,
305 ) -> Result<Self, EnvironmentPolicyError> {
306 let mut launcher_snapshot = capture_process_environment();
307 for name in super::environment_policy::ENV_ALLOWLIST {
308 if let Some(value) = env_lookup(name) {
309 launcher_snapshot.insert((*name).to_string(), value);
310 }
311 }
312 Self::launch_from_snapshot(kind, specs, launcher_snapshot, env_lookup)
313 }
314
315 pub fn launch_from_snapshot(
321 kind: EnvironmentPolicyKind,
322 specs: Vec<GrantSpec>,
323 launcher_snapshot: BTreeMap<String, String>,
324 env_lookup: &dyn Fn(&str) -> Option<String>,
325 ) -> Result<Self, EnvironmentPolicyError> {
326 if !matches!(kind, EnvironmentPolicyKind::Granted) && !specs.is_empty() {
327 return Err(EnvironmentPolicyError::PolicyForbidsGrants {
328 policy: kind,
329 attempted: specs.len(),
330 });
331 }
332 validate_unique_specs(&specs)?;
333 let grants = specs
334 .into_iter()
335 .map(|spec| spec.resolve(env_lookup))
336 .collect::<Result<Vec<_>, _>>()?;
337 let launcher_snapshot = if matches!(kind, EnvironmentPolicyKind::Inherited) {
338 launcher_snapshot
339 } else {
340 launcher_snapshot
341 .into_iter()
342 .filter(|(name, _)| {
343 super::environment_policy::ENV_ALLOWLIST.contains(&name.as_str())
344 })
345 .collect()
346 };
347 Ok(SessionEnvironment {
348 kind,
349 launcher_snapshot,
350 grants,
351 })
352 }
353
354 pub fn isolated() -> Self {
356 Self::launch(EnvironmentPolicyKind::Isolated, Vec::new(), &|name| {
357 std::env::var(name).ok()
358 })
359 .expect("the isolated policy has no fallible grant configuration")
360 }
361
362 pub fn kind(&self) -> EnvironmentPolicyKind {
363 self.kind
364 }
365
366 pub fn is_isolated(&self) -> bool {
367 matches!(self.kind, EnvironmentPolicyKind::Isolated)
368 }
369
370 pub fn allows_implicit_discovery(&self) -> bool {
374 matches!(self.kind, EnvironmentPolicyKind::Inherited)
375 }
376
377 pub fn narrow(
380 &self,
381 requested: EnvironmentPolicyKind,
382 specs: Vec<GrantSpec>,
383 ) -> Result<Self, EnvironmentPolicyError> {
384 match (self.kind, requested) {
385 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Inherited)
386 if specs.is_empty() =>
387 {
388 Ok(self.clone())
389 }
390 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Isolated)
391 if specs.is_empty() =>
392 {
393 Ok(Self {
394 kind: requested,
395 launcher_snapshot: self.launcher_snapshot.clone(),
396 grants: Vec::new(),
397 })
398 }
399 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Granted) => {
400 if let Some(spec) = specs
401 .iter()
402 .find(|spec| matches!(spec.source, GrantSourceSpec::SecretStore { .. }))
403 {
404 return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
405 parent: self.kind,
406 requested,
407 offending_grant: Some(spec.name.trim().to_string()),
408 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(),
409 });
410 }
411 let snapshot = self.launcher_snapshot.clone();
412 Self::launch_from_snapshot(requested, specs, snapshot.clone(), &|name| {
413 snapshot.get(name).cloned()
414 })
415 }
416 (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Isolated)
417 if specs.is_empty() =>
418 {
419 Ok(Self {
420 kind: requested,
421 launcher_snapshot: self.launcher_snapshot.clone(),
422 grants: Vec::new(),
423 })
424 }
425 (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Granted) => {
426 validate_unique_specs(&specs)?;
427 let mut grants = Vec::with_capacity(specs.len());
428 for spec in &specs {
429 let Some(grant) = self.grants.iter().find(|grant| grant.matches_spec(spec))
430 else {
431 return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
432 parent: self.kind,
433 requested,
434 offending_grant: Some(spec.name.trim().to_string()),
435 detail: format!(
436 "grant '{}' is not an unchanged subset of the parent grants",
437 spec.name.trim()
438 ),
439 });
440 };
441 grants.push(grant.clone());
442 }
443 Ok(Self {
444 kind: requested,
445 launcher_snapshot: self.launcher_snapshot.clone(),
446 grants,
447 })
448 }
449 _ => Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
450 parent: self.kind,
451 requested,
452 offending_grant: specs.first().map(|spec| spec.name.trim().to_string()),
453 detail:
454 "a child may keep or reduce its parent's environment access, never widen it"
455 .to_string(),
456 }),
457 }
458 }
459
460 pub(crate) fn launcher_value(&self, name: &str) -> Option<&str> {
461 self.launcher_snapshot.get(name).map(String::as_str)
462 }
463
464 pub(crate) fn launcher_snapshot(&self) -> &BTreeMap<String, String> {
465 &self.launcher_snapshot
466 }
467
468 pub fn grants(&self) -> &[SessionGrant] {
470 &self.grants
471 }
472
473 pub fn receipts(&self) -> Vec<GrantReceipt> {
476 self.grants.iter().map(SessionGrant::receipt).collect()
477 }
478
479 pub fn env_exposure(
489 &self,
490 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
491 ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
492 self.grants
493 .iter()
494 .filter_map(|grant| grant.exposure(resolve_secret))
495 .collect()
496 }
497
498 pub fn env_exposure_for(
508 &self,
509 var: &str,
510 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
511 ) -> Result<Option<String>, EnvironmentPolicyError> {
512 let Some(grant) = self
513 .grants
514 .iter()
515 .find(|grant| grant.expose_as_env.as_deref() == Some(var))
516 else {
517 return Ok(None);
518 };
519 grant
520 .exposure(resolve_secret)
521 .transpose()
522 .map(|pair| pair.map(|(_, value)| value))
523 }
524}
525
526fn capture_process_environment() -> BTreeMap<String, String> {
527 std::env::vars_os()
528 .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?)))
529 .collect()
530}
531
532fn validate_unique_specs(specs: &[GrantSpec]) -> Result<(), EnvironmentPolicyError> {
533 let mut names = BTreeSet::new();
534 let mut targets = BTreeSet::new();
535 for spec in specs {
536 let name = spec.name.trim();
537 if !name.is_empty() && !names.insert(name) {
538 return Err(EnvironmentPolicyError::DuplicateGrant {
539 name: name.to_string(),
540 });
541 }
542 if let Some(target) = spec.expose_as_env.as_deref().map(str::trim) {
543 if !target.is_empty() && !targets.insert(target) {
544 return Err(EnvironmentPolicyError::DuplicateExposureTarget {
545 target: target.to_string(),
546 });
547 }
548 }
549 }
550 Ok(())
551}
552
553#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
559pub struct GrantReceipt {
560 pub name: String,
561 pub source_kind: String,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub exposed_as_env: Option<String>,
564}
565
566#[derive(Clone, Debug, PartialEq, Eq)]
569pub enum EnvironmentPolicyError {
570 EmptyName,
572 EmptyEnvVar { name: String },
574 EmptySecretRef { name: String },
576 EmptyExposeVar { name: String },
578 MissingEnv { name: String, var: String },
580 PolicyForbidsGrants {
582 policy: EnvironmentPolicyKind,
583 attempted: usize,
584 },
585 DuplicateGrant { name: String },
587 DuplicateExposureTarget { target: String },
589 ChildPolicyExceedsParent {
591 parent: EnvironmentPolicyKind,
592 requested: EnvironmentPolicyKind,
593 offending_grant: Option<String>,
594 detail: String,
595 },
596 MissingSecret { name: String },
598}
599
600impl fmt::Display for EnvironmentPolicyError {
601 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
602 match self {
603 EnvironmentPolicyError::EmptyName => write!(
604 f,
605 "[environment_policy.empty_grant_name] grant spec has an empty name"
606 ),
607 EnvironmentPolicyError::EmptyEnvVar { name } => {
608 write!(
609 f,
610 "[environment_policy.empty_source_variable] grant '{name}' env source names an empty variable"
611 )
612 }
613 EnvironmentPolicyError::EmptySecretRef { name } => {
614 write!(
615 f,
616 "[environment_policy.empty_secret_reference] grant '{name}' secret source names an empty account/key"
617 )
618 }
619 EnvironmentPolicyError::EmptyExposeVar { name } => {
620 write!(
621 f,
622 "[environment_policy.empty_exposure_target] grant '{name}' expose target is an empty variable"
623 )
624 }
625 EnvironmentPolicyError::MissingEnv { name, var } => write!(
626 f,
627 "[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"
628 ),
629 EnvironmentPolicyError::PolicyForbidsGrants { policy, attempted } => write!(
630 f,
631 "[environment_policy.grants_forbidden] environment policy '{}' forbids grants, but {attempted} were declared; use 'granted' or remove the grants",
632 policy.as_str()
633 ),
634 EnvironmentPolicyError::DuplicateGrant { name } => write!(
635 f,
636 "[environment_policy.duplicate_grant] grant name '{name}' is declared more than once; give every grant a unique name"
637 ),
638 EnvironmentPolicyError::DuplicateExposureTarget { target } => write!(
639 f,
640 "[environment_policy.duplicate_exposure_target] environment target '{target}' is exposed by more than one grant; choose one grant for each target"
641 ),
642 EnvironmentPolicyError::ChildPolicyExceedsParent {
643 parent,
644 requested,
645 offending_grant: _,
646 detail,
647 } => write!(
648 f,
649 "[environment_policy.child_exceeds_parent] child policy '{}' exceeds parent policy '{}': {detail}",
650 requested.as_str(),
651 parent.as_str()
652 ),
653 EnvironmentPolicyError::MissingSecret { name } => {
654 write!(
655 f,
656 "[environment_policy.secret_unavailable] grant '{name}' is unavailable from the secret store; restore access, rotate the reference, or remove the grant"
657 )
658 }
659 }
660 }
661}
662
663impl std::error::Error for EnvironmentPolicyError {}
664
665impl EnvironmentPolicyError {
666 pub fn code(&self) -> &'static str {
668 match self {
669 Self::EmptyName => "environment_policy.empty_grant_name",
670 Self::EmptyEnvVar { .. } => "environment_policy.empty_source_variable",
671 Self::EmptySecretRef { .. } => "environment_policy.empty_secret_reference",
672 Self::EmptyExposeVar { .. } => "environment_policy.empty_exposure_target",
673 Self::MissingEnv { .. } => "environment_policy.source_variable_missing",
674 Self::PolicyForbidsGrants { .. } => "environment_policy.grants_forbidden",
675 Self::DuplicateGrant { .. } => "environment_policy.duplicate_grant",
676 Self::DuplicateExposureTarget { .. } => "environment_policy.duplicate_exposure_target",
677 Self::ChildPolicyExceedsParent { .. } => "environment_policy.child_exceeds_parent",
678 Self::MissingSecret { .. } => "environment_policy.secret_unavailable",
679 }
680 }
681
682 pub fn to_json(&self) -> serde_json::Value {
684 let mut value = serde_json::json!({
685 "code": self.code(),
686 "message": self.to_string(),
687 });
688 let object = value
689 .as_object_mut()
690 .expect("environment policy diagnostic is an object");
691 match self {
692 Self::EmptyEnvVar { name }
693 | Self::EmptySecretRef { name }
694 | Self::EmptyExposeVar { name }
695 | Self::MissingSecret { name } => {
696 object.insert("grant".to_string(), serde_json::json!(name));
697 }
698 Self::MissingEnv { name, var } => {
699 object.insert("grant".to_string(), serde_json::json!(name));
700 object.insert("sourceVariable".to_string(), serde_json::json!(var));
701 }
702 Self::PolicyForbidsGrants { policy, attempted } => {
703 object.insert("policy".to_string(), serde_json::json!(policy.as_str()));
704 object.insert("attemptedGrants".to_string(), serde_json::json!(attempted));
705 }
706 Self::DuplicateGrant { name } => {
707 object.insert("grant".to_string(), serde_json::json!(name));
708 }
709 Self::DuplicateExposureTarget { target } => {
710 object.insert("target".to_string(), serde_json::json!(target));
711 }
712 Self::ChildPolicyExceedsParent {
713 parent,
714 requested,
715 offending_grant,
716 detail,
717 } => {
718 object.insert(
719 "parentPolicy".to_string(),
720 serde_json::json!(parent.as_str()),
721 );
722 object.insert(
723 "requestedPolicy".to_string(),
724 serde_json::json!(requested.as_str()),
725 );
726 object.insert("detail".to_string(), serde_json::json!(detail));
727 if let Some(grant) = offending_grant {
728 object.insert("grant".to_string(), serde_json::json!(grant));
729 }
730 }
731 Self::EmptyName => {}
732 }
733 value
734 }
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 fn no_env(_: &str) -> Option<String> {
742 None
743 }
744
745 fn env_from(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
746 move |var: &str| {
747 pairs
748 .iter()
749 .find(|(name, _)| *name == var)
750 .map(|(_, value)| value.to_string())
751 }
752 }
753
754 fn env_grant(name: &str, var: &str, expose: Option<&str>) -> GrantSpec {
755 GrantSpec {
756 name: name.to_string(),
757 source: GrantSourceSpec::Env {
758 var: var.to_string(),
759 },
760 expose_as_env: expose.map(str::to_string),
761 }
762 }
763
764 fn secret_grant(name: &str, account: &str, key: &str, expose: Option<&str>) -> GrantSpec {
765 GrantSpec {
766 name: name.to_string(),
767 source: GrantSourceSpec::SecretStore {
768 account: account.to_string(),
769 key: key.to_string(),
770 },
771 expose_as_env: expose.map(str::to_string),
772 }
773 }
774
775 #[test]
776 fn isolated_rejects_any_grant_at_launch() {
777 let specs = vec![secret_grant("gh_token", "gh", "token", None)];
778 let err = SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, specs, &no_env)
779 .expect_err("isolated must reject grants");
780 assert_eq!(
781 err,
782 EnvironmentPolicyError::PolicyForbidsGrants {
783 policy: EnvironmentPolicyKind::Isolated,
784 attempted: 1
785 }
786 );
787
788 let environment =
791 SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, vec![], &no_env).unwrap();
792 assert!(environment.is_isolated());
793 assert!(environment.grants().is_empty());
794 assert!(environment.receipts().is_empty());
795 assert!(SessionEnvironment::isolated().grants().is_empty());
796 assert_eq!(
797 EnvironmentPolicyKind::default(),
798 EnvironmentPolicyKind::Inherited
799 );
800 }
801
802 #[test]
803 fn granted_policy_resolves_once_into_typed_record() {
804 let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
805 let specs = vec![
806 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
807 secret_grant("gh_token", "gh", "token", Some("GH_TOKEN")),
808 ];
809 let environment =
810 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
811
812 let grants = environment.grants();
813 assert_eq!(grants.len(), 2);
814 assert_eq!(grants[0].name(), "fireworks");
816 assert_eq!(grants[0].source_kind(), GrantSource::Env);
817 assert_eq!(grants[0].exposed_env_var(), Some("FIREWORKS_API_KEY"));
818 assert_eq!(grants[1].name(), "gh_token");
819 assert_eq!(grants[1].source_kind(), GrantSource::SecretStore);
820 assert_eq!(grants[1].exposed_env_var(), Some("GH_TOKEN"));
821
822 let resolve_secret = |account: &str, key: &str| -> Option<String> {
825 (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
826 };
827 let mut pairs = environment.env_exposure(&resolve_secret).unwrap();
828 pairs.sort();
829 assert_eq!(
830 pairs,
831 vec![
832 (
833 "FIREWORKS_API_KEY".to_string(),
834 "fw-secret-value".to_string()
835 ),
836 ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
837 ]
838 );
839 }
840
841 #[test]
842 fn secret_pointer_is_not_resolved_at_launch() {
843 let specs = vec![secret_grant("gh_token", "gh", "token", None)];
847 let environment =
848 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env).unwrap();
849 let never = |_: &str, _: &str| -> Option<String> {
850 panic!("secret resolver must not run for an unexposed grant")
851 };
852 assert!(environment.env_exposure(&never).unwrap().is_empty());
853 }
854
855 #[test]
856 fn env_grant_snapshots_value_at_launch() {
857 let at_launch = env_from(&[("TOKEN", "live-at-launch")]);
860 let specs = vec![env_grant("t", "TOKEN", Some("TOKEN"))];
861 let environment =
862 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &at_launch).unwrap();
863
864 let never_secret = |_: &str, _: &str| -> Option<String> { None };
865 let pairs = environment.env_exposure(&never_secret).unwrap();
866 assert_eq!(
867 pairs,
868 vec![("TOKEN".to_string(), "live-at-launch".to_string())]
869 );
870 assert_eq!(
873 environment.env_exposure(&never_secret).unwrap(),
874 vec![("TOKEN".to_string(), "live-at-launch".to_string())]
875 );
876 }
877
878 #[test]
879 fn restricted_policies_do_not_retain_unrelated_launcher_values() {
880 let snapshot = BTreeMap::from([
881 ("PATH".to_string(), "/bin".to_string()),
882 (
883 "UNRELATED_SECRET".to_string(),
884 "must-not-be-retained".to_string(),
885 ),
886 ]);
887 let granted = SessionEnvironment::launch_from_snapshot(
888 EnvironmentPolicyKind::Granted,
889 Vec::new(),
890 snapshot,
891 &no_env,
892 )
893 .unwrap();
894 assert_eq!(granted.launcher_value("PATH"), Some("/bin"));
895 assert_eq!(granted.launcher_value("UNRELATED_SECRET"), None);
896 }
897
898 #[test]
899 fn receipts_record_shape_and_never_the_value() {
900 let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
901 let specs = vec![
902 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
903 secret_grant("gh_token", "gh", "token", None),
904 ];
905 let environment =
906 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
907
908 let receipts = environment.receipts();
909 assert_eq!(
910 receipts,
911 vec![
912 GrantReceipt {
913 name: "fireworks".to_string(),
914 source_kind: "env".to_string(),
915 exposed_as_env: Some("FIREWORKS_API_KEY".to_string()),
916 },
917 GrantReceipt {
918 name: "gh_token".to_string(),
919 source_kind: "secret_store".to_string(),
920 exposed_as_env: None,
921 },
922 ]
923 );
924
925 let json = serde_json::to_string(&receipts).unwrap();
929 assert!(
930 !json.contains("fw-secret-value"),
931 "receipt leaked env value"
932 );
933 assert!(!json.contains("gh/token"), "receipt leaked secret pointer");
934 assert!(json.contains("\"source_kind\":\"env\""));
935 assert!(json.contains("\"source_kind\":\"secret_store\""));
936 }
937
938 #[test]
939 fn grant_spec_is_value_free_over_the_wire() {
940 let spec = env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY"));
943 let json = serde_json::to_string(&spec).unwrap();
944 let round: GrantSpec = serde_json::from_str(&json).unwrap();
945 assert_eq!(round, spec);
946 assert!(json.contains("\"env\""));
947 assert!(json.contains("FIREWORKS_API_KEY"));
948
949 assert_eq!(
951 serde_json::from_str::<EnvironmentPolicyKind>("\"granted\"").unwrap(),
952 EnvironmentPolicyKind::Granted
953 );
954 assert_eq!(
955 EnvironmentPolicyKind::default(),
956 EnvironmentPolicyKind::Inherited
957 );
958 }
959
960 #[test]
961 fn missing_env_source_fails_at_launch() {
962 let specs = vec![env_grant("t", "ABSENT_VAR", None)];
963 let err = SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env)
964 .expect_err("absent env var must fail resolution");
965 assert_eq!(
966 err,
967 EnvironmentPolicyError::MissingEnv {
968 name: "t".to_string(),
969 var: "ABSENT_VAR".to_string(),
970 }
971 );
972 }
973
974 #[test]
975 fn resolve_rejects_empty_fields() {
976 let env = env_from(&[("X", "v")]);
977 assert_eq!(
978 SessionEnvironment::launch(
979 EnvironmentPolicyKind::Granted,
980 vec![env_grant("", "X", None)],
981 &env
982 ),
983 Err(EnvironmentPolicyError::EmptyName)
984 );
985 assert_eq!(
986 SessionEnvironment::launch(
987 EnvironmentPolicyKind::Granted,
988 vec![env_grant("t", "", None)],
989 &env
990 ),
991 Err(EnvironmentPolicyError::EmptyEnvVar {
992 name: "t".to_string()
993 })
994 );
995 assert_eq!(
996 SessionEnvironment::launch(
997 EnvironmentPolicyKind::Granted,
998 vec![secret_grant("t", "acct", "", None)],
999 &env
1000 ),
1001 Err(EnvironmentPolicyError::EmptySecretRef {
1002 name: "t".to_string()
1003 })
1004 );
1005 assert_eq!(
1006 SessionEnvironment::launch(
1007 EnvironmentPolicyKind::Granted,
1008 vec![env_grant("t", "X", Some(" "))],
1009 &env
1010 ),
1011 Err(EnvironmentPolicyError::EmptyExposeVar {
1012 name: "t".to_string()
1013 })
1014 );
1015 }
1016
1017 #[test]
1018 fn duplicate_names_and_targets_fail_with_stable_codes() {
1019 let env = env_from(&[("A", "a"), ("B", "b")]);
1020 let duplicate_name = SessionEnvironment::launch(
1021 EnvironmentPolicyKind::Granted,
1022 vec![
1023 env_grant("token", "A", Some("A")),
1024 env_grant("token", "B", Some("B")),
1025 ],
1026 &env,
1027 )
1028 .unwrap_err();
1029 assert_eq!(duplicate_name.code(), "environment_policy.duplicate_grant");
1030
1031 let duplicate_target = SessionEnvironment::launch(
1032 EnvironmentPolicyKind::Granted,
1033 vec![
1034 env_grant("a", "A", Some("TOKEN")),
1035 env_grant("b", "B", Some("TOKEN")),
1036 ],
1037 &env,
1038 )
1039 .unwrap_err();
1040 assert_eq!(
1041 duplicate_target.code(),
1042 "environment_policy.duplicate_exposure_target"
1043 );
1044 }
1045
1046 #[test]
1047 fn child_policy_can_only_narrow_parent_authority() {
1048 let snapshot = BTreeMap::from([
1049 ("TOKEN".to_string(), "parent-value".to_string()),
1050 ("PATH".to_string(), "/bin".to_string()),
1051 ]);
1052 let parent = SessionEnvironment::launch_from_snapshot(
1053 EnvironmentPolicyKind::Inherited,
1054 Vec::new(),
1055 snapshot.clone(),
1056 &|name| snapshot.get(name).cloned(),
1057 )
1058 .unwrap();
1059 let child = parent
1060 .narrow(
1061 EnvironmentPolicyKind::Granted,
1062 vec![env_grant("token", "TOKEN", Some("TOKEN"))],
1063 )
1064 .unwrap();
1065 assert_eq!(child.kind(), EnvironmentPolicyKind::Granted);
1066 assert_eq!(child.grants().len(), 1);
1067
1068 let error = child
1069 .narrow(EnvironmentPolicyKind::Inherited, Vec::new())
1070 .unwrap_err();
1071 assert_eq!(error.code(), "environment_policy.child_exceeds_parent");
1072 assert_eq!(error.to_json()["parentPolicy"], "granted");
1073 assert_eq!(error.to_json()["requestedPolicy"], "inherited");
1074
1075 let error = child
1076 .narrow(
1077 EnvironmentPolicyKind::Granted,
1078 vec![env_grant("other", "OTHER_TOKEN", Some("OTHER_TOKEN"))],
1079 )
1080 .unwrap_err();
1081 let diagnostic = error.to_json();
1082 assert_eq!(
1083 diagnostic["code"],
1084 "environment_policy.child_exceeds_parent"
1085 );
1086 assert_eq!(diagnostic["parentPolicy"], "granted");
1087 assert_eq!(diagnostic["requestedPolicy"], "granted");
1088 assert_eq!(diagnostic["grant"], "other");
1089 assert!(diagnostic["message"]
1090 .as_str()
1091 .unwrap()
1092 .contains("unchanged subset of the parent grants"));
1093 }
1094}