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 insert_env_value(&mut launcher_snapshot, name, 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, _)| super::environment_policy::allowlist_admits(name))
431 .collect()
432 };
433 Ok(SessionEnvironment {
434 kind,
435 launcher_snapshot,
436 grants,
437 })
438 }
439
440 pub fn isolated() -> Self {
442 Self::launch(EnvironmentPolicyKind::Isolated, Vec::new(), &|name| {
443 std::env::var(name).ok()
444 })
445 .expect("the isolated policy has no fallible grant configuration")
446 }
447
448 pub fn kind(&self) -> EnvironmentPolicyKind {
449 self.kind
450 }
451
452 pub fn is_isolated(&self) -> bool {
453 matches!(self.kind, EnvironmentPolicyKind::Isolated)
454 }
455
456 pub fn allows_implicit_discovery(&self) -> bool {
460 matches!(self.kind, EnvironmentPolicyKind::Inherited)
461 }
462
463 pub fn narrow(
466 &self,
467 requested: EnvironmentPolicyKind,
468 specs: Vec<GrantSpec>,
469 ) -> Result<Self, EnvironmentPolicyError> {
470 match (self.kind, requested) {
471 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Inherited)
472 if specs.is_empty() =>
473 {
474 Ok(self.clone())
475 }
476 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Isolated)
477 if specs.is_empty() =>
478 {
479 Ok(Self {
480 kind: requested,
481 launcher_snapshot: self.launcher_snapshot.clone(),
482 grants: Vec::new(),
483 })
484 }
485 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Granted) => {
486 if let Some(spec) = specs
487 .iter()
488 .find(|spec| matches!(spec.source, GrantSourceSpec::SecretStore { .. }))
489 {
490 return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
491 parent: self.kind,
492 requested,
493 offending_grant: Some(spec.name.trim().to_string()),
494 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(),
495 });
496 }
497 let snapshot = self.launcher_snapshot.clone();
498 Self::launch_from_snapshot(requested, specs, snapshot.clone(), &|name| {
499 snapshot.get(name).cloned()
500 })
501 }
502 (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Isolated)
503 if specs.is_empty() =>
504 {
505 Ok(Self {
506 kind: requested,
507 launcher_snapshot: self.launcher_snapshot.clone(),
508 grants: Vec::new(),
509 })
510 }
511 (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Granted) => {
512 validate_unique_specs(&specs)?;
513 let mut grants = Vec::with_capacity(specs.len());
514 for spec in &specs {
515 let Some(grant) = self.grants.iter().find(|grant| grant.matches_spec(spec))
516 else {
517 return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
518 parent: self.kind,
519 requested,
520 offending_grant: Some(spec.name.trim().to_string()),
521 detail: format!(
522 "grant '{}' is not an unchanged subset of the parent grants",
523 spec.name.trim()
524 ),
525 });
526 };
527 grants.push(grant.clone());
528 }
529 Ok(Self {
530 kind: requested,
531 launcher_snapshot: self.launcher_snapshot.clone(),
532 grants,
533 })
534 }
535 _ => Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
536 parent: self.kind,
537 requested,
538 offending_grant: specs.first().map(|spec| spec.name.trim().to_string()),
539 detail:
540 "a child may keep or reduce its parent's environment access, never widen it"
541 .to_string(),
542 }),
543 }
544 }
545
546 pub(crate) fn launcher_value(&self, name: &str) -> Option<&str> {
558 if let Some(value) = self.launcher_snapshot.get(name) {
559 return Some(value.as_str());
560 }
561 if cfg!(windows) {
562 return self
563 .launcher_snapshot
564 .iter()
565 .find(|(key, _)| key.eq_ignore_ascii_case(name))
566 .map(|(_, value)| value.as_str());
567 }
568 None
569 }
570
571 pub(crate) fn launcher_snapshot(&self) -> &BTreeMap<String, String> {
572 &self.launcher_snapshot
573 }
574
575 pub fn grants(&self) -> &[SessionGrant] {
577 &self.grants
578 }
579
580 pub fn receipts(&self) -> Vec<GrantReceipt> {
583 self.grants.iter().map(SessionGrant::receipt).collect()
584 }
585
586 pub fn env_exposure(
598 &self,
599 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
600 ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
601 self.grants
602 .iter()
603 .filter(|grant| grant.is_session_scoped())
604 .filter_map(|grant| grant.exposure(resolve_secret))
605 .collect()
606 }
607
608 pub fn env_exposure_for_command(
612 &self,
613 program: &str,
614 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
615 ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
616 self.grants
617 .iter()
618 .filter(|grant| grant.applies_to_program(program))
619 .filter_map(|grant| grant.exposure(resolve_secret))
620 .collect()
621 }
622
623 pub fn env_exposure_for(
635 &self,
636 var: &str,
637 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
638 ) -> Result<Option<String>, EnvironmentPolicyError> {
639 let Some(grant) = self
640 .grants
641 .iter()
642 .find(|grant| grant.is_session_scoped() && grant.expose_as_env.as_deref() == Some(var))
643 else {
644 return Ok(None);
645 };
646 grant
647 .exposure(resolve_secret)
648 .transpose()
649 .map(|pair| pair.map(|(_, value)| value))
650 }
651}
652
653fn capture_process_environment() -> BTreeMap<String, String> {
654 std::env::vars_os()
655 .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?)))
656 .collect()
657}
658
659fn insert_env_value_case_insensitive(
665 map: &mut BTreeMap<String, String>,
666 name: &str,
667 value: String,
668) {
669 let existing_key = map
670 .keys()
671 .find(|key| key.eq_ignore_ascii_case(name))
672 .cloned();
673 map.insert(existing_key.unwrap_or_else(|| name.to_string()), value);
674}
675
676fn insert_env_value(map: &mut BTreeMap<String, String>, name: &str, value: String) {
679 if cfg!(windows) {
680 insert_env_value_case_insensitive(map, name, value);
681 } else {
682 map.insert(name.to_string(), value);
683 }
684}
685
686fn validate_unique_specs(specs: &[GrantSpec]) -> Result<(), EnvironmentPolicyError> {
687 let mut names = BTreeSet::new();
688 let mut targets = BTreeSet::new();
689 for spec in specs {
690 let name = spec.name.trim();
691 if !name.is_empty() && !names.insert(name) {
692 return Err(EnvironmentPolicyError::DuplicateGrant {
693 name: name.to_string(),
694 });
695 }
696 if let Some(target) = spec.expose_as_env.as_deref().map(str::trim) {
697 if !target.is_empty() && !targets.insert(target) {
698 return Err(EnvironmentPolicyError::DuplicateExposureTarget {
699 target: target.to_string(),
700 });
701 }
702 }
703 }
704 Ok(())
705}
706
707#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
713pub struct GrantReceipt {
714 pub name: String,
715 pub source_kind: String,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub exposed_as_env: Option<String>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub for_command: Option<String>,
720}
721
722#[derive(Clone, Debug, PartialEq, Eq)]
725pub enum EnvironmentPolicyError {
726 EmptyName,
728 EmptyEnvVar { name: String },
730 EmptySecretRef { name: String },
732 EmptyExposeVar { name: String },
734 EmptyForCommand { name: String },
736 ForWithoutExpose { name: String },
738 InvalidForCommand { name: String, command: String },
741 MissingEnv { name: String, var: String },
743 PolicyForbidsGrants {
745 policy: EnvironmentPolicyKind,
746 attempted: usize,
747 },
748 DuplicateGrant { name: String },
750 DuplicateExposureTarget { target: String },
752 ChildPolicyExceedsParent {
754 parent: EnvironmentPolicyKind,
755 requested: EnvironmentPolicyKind,
756 offending_grant: Option<String>,
757 detail: String,
758 },
759 MissingSecret { name: String },
761}
762
763impl fmt::Display for EnvironmentPolicyError {
764 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
765 match self {
766 EnvironmentPolicyError::EmptyName => write!(
767 f,
768 "[environment_policy.empty_grant_name] grant spec has an empty name"
769 ),
770 EnvironmentPolicyError::EmptyEnvVar { name } => {
771 write!(
772 f,
773 "[environment_policy.empty_source_variable] grant '{name}' env source names an empty variable"
774 )
775 }
776 EnvironmentPolicyError::EmptySecretRef { name } => {
777 write!(
778 f,
779 "[environment_policy.empty_secret_reference] grant '{name}' secret source names an empty account/key"
780 )
781 }
782 EnvironmentPolicyError::EmptyExposeVar { name } => {
783 write!(
784 f,
785 "[environment_policy.empty_exposure_target] grant '{name}' expose target is an empty variable"
786 )
787 }
788 EnvironmentPolicyError::EmptyForCommand { name } => {
789 write!(
790 f,
791 "[environment_policy.empty_for_command] grant '{name}' for_command binding is empty; name the command basename (for example 'gh')"
792 )
793 }
794 EnvironmentPolicyError::ForWithoutExpose { name } => {
795 write!(
796 f,
797 "[environment_policy.for_without_expose] grant '{name}' declares for_command without expose_as_env; command binding only applies to an exposed environment variable"
798 )
799 }
800 EnvironmentPolicyError::InvalidForCommand { name, command } => {
801 write!(
802 f,
803 "[environment_policy.invalid_for_command] grant '{name}' for_command '{command}' must be a command basename, not a path"
804 )
805 }
806 EnvironmentPolicyError::MissingEnv { name, var } => write!(
807 f,
808 "[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"
809 ),
810 EnvironmentPolicyError::PolicyForbidsGrants { policy, attempted } => write!(
811 f,
812 "[environment_policy.grants_forbidden] environment policy '{}' forbids grants, but {attempted} were declared; use 'granted' or remove the grants",
813 policy.as_str()
814 ),
815 EnvironmentPolicyError::DuplicateGrant { name } => write!(
816 f,
817 "[environment_policy.duplicate_grant] grant name '{name}' is declared more than once; give every grant a unique name"
818 ),
819 EnvironmentPolicyError::DuplicateExposureTarget { target } => write!(
820 f,
821 "[environment_policy.duplicate_exposure_target] environment target '{target}' is exposed by more than one grant; choose one grant for each target"
822 ),
823 EnvironmentPolicyError::ChildPolicyExceedsParent {
824 parent,
825 requested,
826 offending_grant: _,
827 detail,
828 } => write!(
829 f,
830 "[environment_policy.child_exceeds_parent] child policy '{}' exceeds parent policy '{}': {detail}",
831 requested.as_str(),
832 parent.as_str()
833 ),
834 EnvironmentPolicyError::MissingSecret { name } => {
835 write!(
836 f,
837 "[environment_policy.secret_unavailable] grant '{name}' is unavailable from the secret store; restore access, rotate the reference, or remove the grant"
838 )
839 }
840 }
841 }
842}
843
844impl std::error::Error for EnvironmentPolicyError {}
845
846impl EnvironmentPolicyError {
847 pub fn code(&self) -> &'static str {
849 match self {
850 Self::EmptyName => "environment_policy.empty_grant_name",
851 Self::EmptyEnvVar { .. } => "environment_policy.empty_source_variable",
852 Self::EmptySecretRef { .. } => "environment_policy.empty_secret_reference",
853 Self::EmptyExposeVar { .. } => "environment_policy.empty_exposure_target",
854 Self::EmptyForCommand { .. } => "environment_policy.empty_for_command",
855 Self::ForWithoutExpose { .. } => "environment_policy.for_without_expose",
856 Self::InvalidForCommand { .. } => "environment_policy.invalid_for_command",
857 Self::MissingEnv { .. } => "environment_policy.source_variable_missing",
858 Self::PolicyForbidsGrants { .. } => "environment_policy.grants_forbidden",
859 Self::DuplicateGrant { .. } => "environment_policy.duplicate_grant",
860 Self::DuplicateExposureTarget { .. } => "environment_policy.duplicate_exposure_target",
861 Self::ChildPolicyExceedsParent { .. } => "environment_policy.child_exceeds_parent",
862 Self::MissingSecret { .. } => "environment_policy.secret_unavailable",
863 }
864 }
865
866 pub fn to_json(&self) -> serde_json::Value {
868 let mut value = serde_json::json!({
869 "code": self.code(),
870 "message": self.to_string(),
871 });
872 let object = value
873 .as_object_mut()
874 .expect("environment policy diagnostic is an object");
875 match self {
876 Self::EmptyEnvVar { name }
877 | Self::EmptySecretRef { name }
878 | Self::EmptyExposeVar { name }
879 | Self::EmptyForCommand { name }
880 | Self::ForWithoutExpose { name }
881 | Self::MissingSecret { name } => {
882 object.insert("grant".to_string(), serde_json::json!(name));
883 }
884 Self::InvalidForCommand { name, command } => {
885 object.insert("grant".to_string(), serde_json::json!(name));
886 object.insert("forCommand".to_string(), serde_json::json!(command));
887 }
888 Self::MissingEnv { name, var } => {
889 object.insert("grant".to_string(), serde_json::json!(name));
890 object.insert("sourceVariable".to_string(), serde_json::json!(var));
891 }
892 Self::PolicyForbidsGrants { policy, attempted } => {
893 object.insert("policy".to_string(), serde_json::json!(policy.as_str()));
894 object.insert("attemptedGrants".to_string(), serde_json::json!(attempted));
895 }
896 Self::DuplicateGrant { name } => {
897 object.insert("grant".to_string(), serde_json::json!(name));
898 }
899 Self::DuplicateExposureTarget { target } => {
900 object.insert("target".to_string(), serde_json::json!(target));
901 }
902 Self::ChildPolicyExceedsParent {
903 parent,
904 requested,
905 offending_grant,
906 detail,
907 } => {
908 object.insert(
909 "parentPolicy".to_string(),
910 serde_json::json!(parent.as_str()),
911 );
912 object.insert(
913 "requestedPolicy".to_string(),
914 serde_json::json!(requested.as_str()),
915 );
916 object.insert("detail".to_string(), serde_json::json!(detail));
917 if let Some(grant) = offending_grant {
918 object.insert("grant".to_string(), serde_json::json!(grant));
919 }
920 }
921 Self::EmptyName => {}
922 }
923 value
924 }
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930
931 fn no_env(_: &str) -> Option<String> {
932 None
933 }
934
935 fn env_from(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
936 move |var: &str| {
937 pairs
938 .iter()
939 .find(|(name, _)| *name == var)
940 .map(|(_, value)| value.to_string())
941 }
942 }
943
944 fn env_grant(name: &str, var: &str, expose: Option<&str>) -> GrantSpec {
945 GrantSpec {
946 name: name.to_string(),
947 source: GrantSourceSpec::Env {
948 var: var.to_string(),
949 },
950 expose_as_env: expose.map(str::to_string),
951 for_command: None,
952 }
953 }
954
955 fn secret_grant(name: &str, account: &str, key: &str, expose: Option<&str>) -> GrantSpec {
956 GrantSpec {
957 name: name.to_string(),
958 source: GrantSourceSpec::SecretStore {
959 account: account.to_string(),
960 key: key.to_string(),
961 },
962 expose_as_env: expose.map(str::to_string),
963 for_command: None,
964 }
965 }
966
967 fn command_grant(
968 name: &str,
969 account: &str,
970 key: &str,
971 expose: &str,
972 for_command: &str,
973 ) -> GrantSpec {
974 GrantSpec {
975 name: name.to_string(),
976 source: GrantSourceSpec::SecretStore {
977 account: account.to_string(),
978 key: key.to_string(),
979 },
980 expose_as_env: Some(expose.to_string()),
981 for_command: Some(for_command.to_string()),
982 }
983 }
984
985 #[test]
986 fn isolated_rejects_any_grant_at_launch() {
987 let specs = vec![secret_grant("gh_token", "gh", "token", None)];
988 let err = SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, specs, &no_env)
989 .expect_err("isolated must reject grants");
990 assert_eq!(
991 err,
992 EnvironmentPolicyError::PolicyForbidsGrants {
993 policy: EnvironmentPolicyKind::Isolated,
994 attempted: 1
995 }
996 );
997
998 let environment =
1001 SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, vec![], &no_env).unwrap();
1002 assert!(environment.is_isolated());
1003 assert!(environment.grants().is_empty());
1004 assert!(environment.receipts().is_empty());
1005 assert!(SessionEnvironment::isolated().grants().is_empty());
1006 assert_eq!(
1007 EnvironmentPolicyKind::default(),
1008 EnvironmentPolicyKind::Inherited
1009 );
1010 }
1011
1012 #[test]
1013 fn granted_policy_resolves_once_into_typed_record() {
1014 let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
1015 let specs = vec![
1016 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1017 secret_grant("gh_token", "gh", "token", Some("GH_TOKEN")),
1018 ];
1019 let environment =
1020 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
1021
1022 let grants = environment.grants();
1023 assert_eq!(grants.len(), 2);
1024 assert_eq!(grants[0].name(), "fireworks");
1026 assert_eq!(grants[0].source_kind(), GrantSource::Env);
1027 assert_eq!(grants[0].exposed_env_var(), Some("FIREWORKS_API_KEY"));
1028 assert_eq!(grants[1].name(), "gh_token");
1029 assert_eq!(grants[1].source_kind(), GrantSource::SecretStore);
1030 assert_eq!(grants[1].exposed_env_var(), Some("GH_TOKEN"));
1031
1032 let resolve_secret = |account: &str, key: &str| -> Option<String> {
1035 (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
1036 };
1037 let mut pairs = environment.env_exposure(&resolve_secret).unwrap();
1038 pairs.sort();
1039 assert_eq!(
1040 pairs,
1041 vec![
1042 (
1043 "FIREWORKS_API_KEY".to_string(),
1044 "fw-secret-value".to_string()
1045 ),
1046 ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
1047 ]
1048 );
1049 }
1050
1051 #[test]
1052 fn secret_pointer_is_not_resolved_at_launch() {
1053 let specs = vec![secret_grant("gh_token", "gh", "token", None)];
1057 let environment =
1058 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env).unwrap();
1059 let never = |_: &str, _: &str| -> Option<String> {
1060 panic!("secret resolver must not run for an unexposed grant")
1061 };
1062 assert!(environment.env_exposure(&never).unwrap().is_empty());
1063 }
1064
1065 #[test]
1066 fn env_grant_snapshots_value_at_launch() {
1067 let at_launch = env_from(&[("TOKEN", "live-at-launch")]);
1070 let specs = vec![env_grant("t", "TOKEN", Some("TOKEN"))];
1071 let environment =
1072 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &at_launch).unwrap();
1073
1074 let never_secret = |_: &str, _: &str| -> Option<String> { None };
1075 let pairs = environment.env_exposure(&never_secret).unwrap();
1076 assert_eq!(
1077 pairs,
1078 vec![("TOKEN".to_string(), "live-at-launch".to_string())]
1079 );
1080 assert_eq!(
1083 environment.env_exposure(&never_secret).unwrap(),
1084 vec![("TOKEN".to_string(), "live-at-launch".to_string())]
1085 );
1086 }
1087
1088 #[test]
1089 fn restricted_policies_do_not_retain_unrelated_launcher_values() {
1090 let snapshot = BTreeMap::from([
1091 ("PATH".to_string(), "/bin".to_string()),
1092 (
1093 "UNRELATED_SECRET".to_string(),
1094 "must-not-be-retained".to_string(),
1095 ),
1096 ]);
1097 let granted = SessionEnvironment::launch_from_snapshot(
1098 EnvironmentPolicyKind::Granted,
1099 Vec::new(),
1100 snapshot,
1101 &no_env,
1102 )
1103 .unwrap();
1104 assert_eq!(granted.launcher_value("PATH"), Some("/bin"));
1105 assert_eq!(granted.launcher_value("UNRELATED_SECRET"), None);
1106 }
1107
1108 #[test]
1109 fn receipts_record_shape_and_never_the_value() {
1110 let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
1111 let specs = vec![
1112 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1113 secret_grant("gh_token", "gh", "token", None),
1114 ];
1115 let environment =
1116 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
1117
1118 let receipts = environment.receipts();
1119 assert_eq!(
1120 receipts,
1121 vec![
1122 GrantReceipt {
1123 name: "fireworks".to_string(),
1124 source_kind: "env".to_string(),
1125 exposed_as_env: Some("FIREWORKS_API_KEY".to_string()),
1126 for_command: None,
1127 },
1128 GrantReceipt {
1129 name: "gh_token".to_string(),
1130 source_kind: "secret_store".to_string(),
1131 exposed_as_env: None,
1132 for_command: None,
1133 },
1134 ]
1135 );
1136
1137 let json = serde_json::to_string(&receipts).unwrap();
1141 assert!(
1142 !json.contains("fw-secret-value"),
1143 "receipt leaked env value"
1144 );
1145 assert!(!json.contains("gh/token"), "receipt leaked secret pointer");
1146 assert!(json.contains("\"source_kind\":\"env\""));
1147 assert!(json.contains("\"source_kind\":\"secret_store\""));
1148 }
1149
1150 #[test]
1151 fn grant_spec_is_value_free_over_the_wire() {
1152 let spec = env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY"));
1155 let json = serde_json::to_string(&spec).unwrap();
1156 let round: GrantSpec = serde_json::from_str(&json).unwrap();
1157 assert_eq!(round, spec);
1158 assert!(json.contains("\"env\""));
1159 assert!(json.contains("FIREWORKS_API_KEY"));
1160
1161 assert_eq!(
1163 serde_json::from_str::<EnvironmentPolicyKind>("\"granted\"").unwrap(),
1164 EnvironmentPolicyKind::Granted
1165 );
1166 assert_eq!(
1167 EnvironmentPolicyKind::default(),
1168 EnvironmentPolicyKind::Inherited
1169 );
1170 }
1171
1172 #[test]
1173 fn missing_env_source_fails_at_launch() {
1174 let specs = vec![env_grant("t", "ABSENT_VAR", None)];
1175 let err = SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env)
1176 .expect_err("absent env var must fail resolution");
1177 assert_eq!(
1178 err,
1179 EnvironmentPolicyError::MissingEnv {
1180 name: "t".to_string(),
1181 var: "ABSENT_VAR".to_string(),
1182 }
1183 );
1184 }
1185
1186 #[test]
1187 fn resolve_rejects_empty_fields() {
1188 let env = env_from(&[("X", "v")]);
1189 assert_eq!(
1190 SessionEnvironment::launch(
1191 EnvironmentPolicyKind::Granted,
1192 vec![env_grant("", "X", None)],
1193 &env
1194 ),
1195 Err(EnvironmentPolicyError::EmptyName)
1196 );
1197 assert_eq!(
1198 SessionEnvironment::launch(
1199 EnvironmentPolicyKind::Granted,
1200 vec![env_grant("t", "", None)],
1201 &env
1202 ),
1203 Err(EnvironmentPolicyError::EmptyEnvVar {
1204 name: "t".to_string()
1205 })
1206 );
1207 assert_eq!(
1208 SessionEnvironment::launch(
1209 EnvironmentPolicyKind::Granted,
1210 vec![secret_grant("t", "acct", "", None)],
1211 &env
1212 ),
1213 Err(EnvironmentPolicyError::EmptySecretRef {
1214 name: "t".to_string()
1215 })
1216 );
1217 assert_eq!(
1218 SessionEnvironment::launch(
1219 EnvironmentPolicyKind::Granted,
1220 vec![env_grant("t", "X", Some(" "))],
1221 &env
1222 ),
1223 Err(EnvironmentPolicyError::EmptyExposeVar {
1224 name: "t".to_string()
1225 })
1226 );
1227 }
1228
1229 #[test]
1230 fn duplicate_names_and_targets_fail_with_stable_codes() {
1231 let env = env_from(&[("A", "a"), ("B", "b")]);
1232 let duplicate_name = SessionEnvironment::launch(
1233 EnvironmentPolicyKind::Granted,
1234 vec![
1235 env_grant("token", "A", Some("A")),
1236 env_grant("token", "B", Some("B")),
1237 ],
1238 &env,
1239 )
1240 .unwrap_err();
1241 assert_eq!(duplicate_name.code(), "environment_policy.duplicate_grant");
1242
1243 let duplicate_target = SessionEnvironment::launch(
1244 EnvironmentPolicyKind::Granted,
1245 vec![
1246 env_grant("a", "A", Some("TOKEN")),
1247 env_grant("b", "B", Some("TOKEN")),
1248 ],
1249 &env,
1250 )
1251 .unwrap_err();
1252 assert_eq!(
1253 duplicate_target.code(),
1254 "environment_policy.duplicate_exposure_target"
1255 );
1256 }
1257
1258 #[test]
1259 fn child_policy_can_only_narrow_parent_authority() {
1260 let snapshot = BTreeMap::from([
1261 ("TOKEN".to_string(), "parent-value".to_string()),
1262 ("PATH".to_string(), "/bin".to_string()),
1263 ]);
1264 let parent = SessionEnvironment::launch_from_snapshot(
1265 EnvironmentPolicyKind::Inherited,
1266 Vec::new(),
1267 snapshot.clone(),
1268 &|name| snapshot.get(name).cloned(),
1269 )
1270 .unwrap();
1271 let child = parent
1272 .narrow(
1273 EnvironmentPolicyKind::Granted,
1274 vec![env_grant("token", "TOKEN", Some("TOKEN"))],
1275 )
1276 .unwrap();
1277 assert_eq!(child.kind(), EnvironmentPolicyKind::Granted);
1278 assert_eq!(child.grants().len(), 1);
1279
1280 let error = child
1281 .narrow(EnvironmentPolicyKind::Inherited, Vec::new())
1282 .unwrap_err();
1283 assert_eq!(error.code(), "environment_policy.child_exceeds_parent");
1284 assert_eq!(error.to_json()["parentPolicy"], "granted");
1285 assert_eq!(error.to_json()["requestedPolicy"], "inherited");
1286
1287 let error = child
1288 .narrow(
1289 EnvironmentPolicyKind::Granted,
1290 vec![env_grant("other", "OTHER_TOKEN", Some("OTHER_TOKEN"))],
1291 )
1292 .unwrap_err();
1293 let diagnostic = error.to_json();
1294 assert_eq!(
1295 diagnostic["code"],
1296 "environment_policy.child_exceeds_parent"
1297 );
1298 assert_eq!(diagnostic["parentPolicy"], "granted");
1299 assert_eq!(diagnostic["requestedPolicy"], "granted");
1300 assert_eq!(diagnostic["grant"], "other");
1301 assert!(diagnostic["message"]
1302 .as_str()
1303 .unwrap()
1304 .contains("unchanged subset of the parent grants"));
1305 }
1306
1307 #[test]
1308 fn command_bound_grant_is_absent_from_session_exposure() {
1309 let resolve_secret = |account: &str, key: &str| -> Option<String> {
1310 (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
1311 };
1312 let environment = SessionEnvironment::launch(
1313 EnvironmentPolicyKind::Granted,
1314 vec![
1315 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1316 command_grant("gh_token", "gh", "token", "GH_TOKEN", "gh"),
1317 ],
1318 &env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]),
1319 )
1320 .unwrap();
1321
1322 let ambient = environment.env_exposure(&resolve_secret).unwrap();
1324 assert_eq!(
1325 ambient,
1326 vec![(
1327 "FIREWORKS_API_KEY".to_string(),
1328 "fw-secret-value".to_string()
1329 )]
1330 );
1331 assert_eq!(
1332 environment
1333 .env_exposure_for("GH_TOKEN", &resolve_secret)
1334 .unwrap(),
1335 None
1336 );
1337
1338 let mut for_gh = environment
1340 .env_exposure_for_command("gh", &resolve_secret)
1341 .unwrap();
1342 for_gh.sort();
1343 assert_eq!(
1344 for_gh,
1345 vec![
1346 (
1347 "FIREWORKS_API_KEY".to_string(),
1348 "fw-secret-value".to_string()
1349 ),
1350 ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
1351 ]
1352 );
1353 let for_git = environment
1354 .env_exposure_for_command("/usr/bin/git", &resolve_secret)
1355 .unwrap();
1356 assert_eq!(
1357 for_git,
1358 vec![(
1359 "FIREWORKS_API_KEY".to_string(),
1360 "fw-secret-value".to_string()
1361 )]
1362 );
1363 assert!(environment
1364 .env_exposure_for_command("/usr/local/bin/gh", &resolve_secret)
1365 .unwrap()
1366 .into_iter()
1367 .any(|(var, _)| var == "GH_TOKEN"));
1368 assert_eq!(command_basename("C:\\Tools\\gh.exe"), "gh");
1369
1370 let receipts = environment.receipts();
1371 assert_eq!(receipts[1].for_command.as_deref(), Some("gh"));
1372 assert_eq!(receipts[1].exposed_as_env.as_deref(), Some("GH_TOKEN"));
1373 }
1374
1375 #[test]
1376 fn for_command_requires_expose_and_rejects_paths() {
1377 let err = SessionEnvironment::launch(
1378 EnvironmentPolicyKind::Granted,
1379 vec![GrantSpec {
1380 name: "gh_token".to_string(),
1381 source: GrantSourceSpec::SecretStore {
1382 account: "gh".to_string(),
1383 key: "token".to_string(),
1384 },
1385 expose_as_env: None,
1386 for_command: Some("gh".to_string()),
1387 }],
1388 &no_env,
1389 )
1390 .unwrap_err();
1391 assert_eq!(err.code(), "environment_policy.for_without_expose");
1392
1393 let err = SessionEnvironment::launch(
1394 EnvironmentPolicyKind::Granted,
1395 vec![GrantSpec {
1396 name: "gh_token".to_string(),
1397 source: GrantSourceSpec::SecretStore {
1398 account: "gh".to_string(),
1399 key: "token".to_string(),
1400 },
1401 expose_as_env: Some("GH_TOKEN".to_string()),
1402 for_command: Some("/usr/bin/gh".to_string()),
1403 }],
1404 &no_env,
1405 )
1406 .unwrap_err();
1407 assert_eq!(err.code(), "environment_policy.invalid_for_command");
1408 }
1409
1410 #[test]
1413 fn a_case_insensitive_insert_updates_the_existing_key_not_a_second_one() {
1414 let mut map = BTreeMap::new();
1415 map.insert(
1416 "Path".to_string(),
1417 "C:\\nodejs;C:\\Windows\\System32".to_string(),
1418 );
1419 insert_env_value_case_insensitive(
1420 &mut map,
1421 "PATH",
1422 "C:\\nodejs;C:\\Windows\\System32;C:\\extra".to_string(),
1423 );
1424 assert_eq!(
1425 map.len(),
1426 1,
1427 "must update the existing 'Path' key, not add a second 'PATH' key: {map:?}"
1428 );
1429 assert_eq!(
1430 map.get("Path").map(String::as_str),
1431 Some("C:\\nodejs;C:\\Windows\\System32;C:\\extra"),
1432 "the original casing is preserved, only the value is refreshed: {map:?}"
1433 );
1434 assert!(
1435 !map.contains_key("PATH"),
1436 "no second key should exist under the allowlist's own casing: {map:?}"
1437 );
1438 }
1439
1440 #[test]
1441 fn a_case_insensitive_insert_adds_a_new_key_when_none_matches() {
1442 let mut map = BTreeMap::new();
1443 insert_env_value_case_insensitive(&mut map, "HOME", "/root".to_string());
1444 assert_eq!(map.get("HOME").map(String::as_str), Some("/root"));
1445 }
1446
1447 #[cfg(windows)]
1451 #[test]
1452 fn an_inherited_child_resolves_node_whenever_the_parent_does() {
1453 let parent_has_node = std::process::Command::new("cmd")
1454 .args(["/D", "/C", "where node"])
1455 .output()
1456 .map(|output| output.status.success())
1457 .unwrap_or(false);
1458 if !parent_has_node {
1459 return; }
1461 let environment =
1462 SessionEnvironment::launch(EnvironmentPolicyKind::Inherited, vec![], &no_env)
1463 .expect("inherited launch never fails");
1464 let resolve_secret = |_: &str, _: &str| -> Option<String> { None };
1465 let env = crate::security::resolve_env(&environment, &no_env, &resolve_secret)
1466 .expect("inherited resolve_env never fails");
1467 let path_entries: Vec<(&String, &String)> = env
1468 .iter()
1469 .filter(|(key, _)| key.eq_ignore_ascii_case("PATH"))
1470 .collect();
1471 assert_eq!(
1472 path_entries.len(),
1473 1,
1474 "exactly one PATH-shaped key must reach the child: {:?}",
1475 env.keys().collect::<Vec<_>>()
1476 );
1477 let parent_path = std::env::var("PATH").expect("this process has a PATH to compare");
1479 assert_eq!(
1480 path_entries[0].1, &parent_path,
1481 "an Inherited child's PATH must equal the parent's PATH exactly"
1482 );
1483 let output = std::process::Command::new("cmd")
1484 .args(["/D", "/C", "where node"])
1485 .env_clear()
1486 .envs(&env)
1487 .output()
1488 .expect("spawn cmd for the child-side probe");
1489 assert!(
1490 output.status.success(),
1491 "parent resolved node on PATH but an Inherited-policy child did not: \
1492 stdout={:?} stderr={:?}",
1493 String::from_utf8_lossy(&output.stdout),
1494 String::from_utf8_lossy(&output.stderr),
1495 );
1496 }
1497}