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 if name.len() > suffix.len()
328 && name[name.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
329 {
330 return &name[..name.len() - suffix.len()];
331 }
332 }
333 name
334}
335
336#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum EnvironmentPolicyKind {
341 #[default]
343 Inherited,
344 Isolated,
346 Granted,
348}
349
350impl EnvironmentPolicyKind {
351 pub fn as_str(self) -> &'static str {
352 match self {
353 EnvironmentPolicyKind::Inherited => "inherited",
354 EnvironmentPolicyKind::Isolated => "isolated",
355 EnvironmentPolicyKind::Granted => "granted",
356 }
357 }
358}
359
360#[derive(Clone, Debug, PartialEq, Eq)]
365pub struct SessionEnvironment {
366 kind: EnvironmentPolicyKind,
367 launcher_snapshot: BTreeMap<String, String>,
368 grants: Vec<SessionGrant>,
369}
370
371impl SessionEnvironment {
372 pub fn inherited() -> Self {
374 Self::launch(EnvironmentPolicyKind::Inherited, Vec::new(), &|name| {
375 std::env::var(name).ok()
376 })
377 .expect("the inherited policy has no fallible grant configuration")
378 }
379
380 pub fn launch(
387 kind: EnvironmentPolicyKind,
388 specs: Vec<GrantSpec>,
389 env_lookup: &dyn Fn(&str) -> Option<String>,
390 ) -> Result<Self, EnvironmentPolicyError> {
391 let mut launcher_snapshot = capture_process_environment();
392 for name in super::environment_policy::ENV_ALLOWLIST {
393 if let Some(value) = env_lookup(name) {
394 launcher_snapshot.insert((*name).to_string(), value);
395 }
396 }
397 Self::launch_from_snapshot(kind, specs, launcher_snapshot, env_lookup)
398 }
399
400 pub fn launch_from_snapshot(
406 kind: EnvironmentPolicyKind,
407 specs: Vec<GrantSpec>,
408 launcher_snapshot: BTreeMap<String, String>,
409 env_lookup: &dyn Fn(&str) -> Option<String>,
410 ) -> Result<Self, EnvironmentPolicyError> {
411 if !matches!(kind, EnvironmentPolicyKind::Granted) && !specs.is_empty() {
412 return Err(EnvironmentPolicyError::PolicyForbidsGrants {
413 policy: kind,
414 attempted: specs.len(),
415 });
416 }
417 validate_unique_specs(&specs)?;
418 let grants = specs
419 .into_iter()
420 .map(|spec| spec.resolve(env_lookup))
421 .collect::<Result<Vec<_>, _>>()?;
422 let launcher_snapshot = if matches!(kind, EnvironmentPolicyKind::Inherited) {
423 launcher_snapshot
424 } else {
425 launcher_snapshot
426 .into_iter()
427 .filter(|(name, _)| {
428 super::environment_policy::ENV_ALLOWLIST.contains(&name.as_str())
429 })
430 .collect()
431 };
432 Ok(SessionEnvironment {
433 kind,
434 launcher_snapshot,
435 grants,
436 })
437 }
438
439 pub fn isolated() -> Self {
441 Self::launch(EnvironmentPolicyKind::Isolated, Vec::new(), &|name| {
442 std::env::var(name).ok()
443 })
444 .expect("the isolated policy has no fallible grant configuration")
445 }
446
447 pub fn kind(&self) -> EnvironmentPolicyKind {
448 self.kind
449 }
450
451 pub fn is_isolated(&self) -> bool {
452 matches!(self.kind, EnvironmentPolicyKind::Isolated)
453 }
454
455 pub fn allows_implicit_discovery(&self) -> bool {
459 matches!(self.kind, EnvironmentPolicyKind::Inherited)
460 }
461
462 pub fn narrow(
465 &self,
466 requested: EnvironmentPolicyKind,
467 specs: Vec<GrantSpec>,
468 ) -> Result<Self, EnvironmentPolicyError> {
469 match (self.kind, requested) {
470 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Inherited)
471 if specs.is_empty() =>
472 {
473 Ok(self.clone())
474 }
475 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Isolated)
476 if specs.is_empty() =>
477 {
478 Ok(Self {
479 kind: requested,
480 launcher_snapshot: self.launcher_snapshot.clone(),
481 grants: Vec::new(),
482 })
483 }
484 (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Granted) => {
485 if let Some(spec) = specs
486 .iter()
487 .find(|spec| matches!(spec.source, GrantSourceSpec::SecretStore { .. }))
488 {
489 return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
490 parent: self.kind,
491 requested,
492 offending_grant: Some(spec.name.trim().to_string()),
493 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(),
494 });
495 }
496 let snapshot = self.launcher_snapshot.clone();
497 Self::launch_from_snapshot(requested, specs, snapshot.clone(), &|name| {
498 snapshot.get(name).cloned()
499 })
500 }
501 (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Isolated)
502 if specs.is_empty() =>
503 {
504 Ok(Self {
505 kind: requested,
506 launcher_snapshot: self.launcher_snapshot.clone(),
507 grants: Vec::new(),
508 })
509 }
510 (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Granted) => {
511 validate_unique_specs(&specs)?;
512 let mut grants = Vec::with_capacity(specs.len());
513 for spec in &specs {
514 let Some(grant) = self.grants.iter().find(|grant| grant.matches_spec(spec))
515 else {
516 return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
517 parent: self.kind,
518 requested,
519 offending_grant: Some(spec.name.trim().to_string()),
520 detail: format!(
521 "grant '{}' is not an unchanged subset of the parent grants",
522 spec.name.trim()
523 ),
524 });
525 };
526 grants.push(grant.clone());
527 }
528 Ok(Self {
529 kind: requested,
530 launcher_snapshot: self.launcher_snapshot.clone(),
531 grants,
532 })
533 }
534 _ => Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
535 parent: self.kind,
536 requested,
537 offending_grant: specs.first().map(|spec| spec.name.trim().to_string()),
538 detail:
539 "a child may keep or reduce its parent's environment access, never widen it"
540 .to_string(),
541 }),
542 }
543 }
544
545 pub(crate) fn launcher_value(&self, name: &str) -> Option<&str> {
546 self.launcher_snapshot.get(name).map(String::as_str)
547 }
548
549 pub(crate) fn launcher_snapshot(&self) -> &BTreeMap<String, String> {
550 &self.launcher_snapshot
551 }
552
553 pub fn grants(&self) -> &[SessionGrant] {
555 &self.grants
556 }
557
558 pub fn receipts(&self) -> Vec<GrantReceipt> {
561 self.grants.iter().map(SessionGrant::receipt).collect()
562 }
563
564 pub fn env_exposure(
576 &self,
577 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
578 ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
579 self.grants
580 .iter()
581 .filter(|grant| grant.is_session_scoped())
582 .filter_map(|grant| grant.exposure(resolve_secret))
583 .collect()
584 }
585
586 pub fn env_exposure_for_command(
590 &self,
591 program: &str,
592 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
593 ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
594 self.grants
595 .iter()
596 .filter(|grant| grant.applies_to_program(program))
597 .filter_map(|grant| grant.exposure(resolve_secret))
598 .collect()
599 }
600
601 pub fn env_exposure_for(
613 &self,
614 var: &str,
615 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
616 ) -> Result<Option<String>, EnvironmentPolicyError> {
617 let Some(grant) = self
618 .grants
619 .iter()
620 .find(|grant| grant.is_session_scoped() && grant.expose_as_env.as_deref() == Some(var))
621 else {
622 return Ok(None);
623 };
624 grant
625 .exposure(resolve_secret)
626 .transpose()
627 .map(|pair| pair.map(|(_, value)| value))
628 }
629}
630
631fn capture_process_environment() -> BTreeMap<String, String> {
632 std::env::vars_os()
633 .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?)))
634 .collect()
635}
636
637fn validate_unique_specs(specs: &[GrantSpec]) -> Result<(), EnvironmentPolicyError> {
638 let mut names = BTreeSet::new();
639 let mut targets = BTreeSet::new();
640 for spec in specs {
641 let name = spec.name.trim();
642 if !name.is_empty() && !names.insert(name) {
643 return Err(EnvironmentPolicyError::DuplicateGrant {
644 name: name.to_string(),
645 });
646 }
647 if let Some(target) = spec.expose_as_env.as_deref().map(str::trim) {
648 if !target.is_empty() && !targets.insert(target) {
649 return Err(EnvironmentPolicyError::DuplicateExposureTarget {
650 target: target.to_string(),
651 });
652 }
653 }
654 }
655 Ok(())
656}
657
658#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
664pub struct GrantReceipt {
665 pub name: String,
666 pub source_kind: String,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
668 pub exposed_as_env: Option<String>,
669 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub for_command: Option<String>,
671}
672
673#[derive(Clone, Debug, PartialEq, Eq)]
676pub enum EnvironmentPolicyError {
677 EmptyName,
679 EmptyEnvVar { name: String },
681 EmptySecretRef { name: String },
683 EmptyExposeVar { name: String },
685 EmptyForCommand { name: String },
687 ForWithoutExpose { name: String },
689 InvalidForCommand { name: String, command: String },
692 MissingEnv { name: String, var: String },
694 PolicyForbidsGrants {
696 policy: EnvironmentPolicyKind,
697 attempted: usize,
698 },
699 DuplicateGrant { name: String },
701 DuplicateExposureTarget { target: String },
703 ChildPolicyExceedsParent {
705 parent: EnvironmentPolicyKind,
706 requested: EnvironmentPolicyKind,
707 offending_grant: Option<String>,
708 detail: String,
709 },
710 MissingSecret { name: String },
712}
713
714impl fmt::Display for EnvironmentPolicyError {
715 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
716 match self {
717 EnvironmentPolicyError::EmptyName => write!(
718 f,
719 "[environment_policy.empty_grant_name] grant spec has an empty name"
720 ),
721 EnvironmentPolicyError::EmptyEnvVar { name } => {
722 write!(
723 f,
724 "[environment_policy.empty_source_variable] grant '{name}' env source names an empty variable"
725 )
726 }
727 EnvironmentPolicyError::EmptySecretRef { name } => {
728 write!(
729 f,
730 "[environment_policy.empty_secret_reference] grant '{name}' secret source names an empty account/key"
731 )
732 }
733 EnvironmentPolicyError::EmptyExposeVar { name } => {
734 write!(
735 f,
736 "[environment_policy.empty_exposure_target] grant '{name}' expose target is an empty variable"
737 )
738 }
739 EnvironmentPolicyError::EmptyForCommand { name } => {
740 write!(
741 f,
742 "[environment_policy.empty_for_command] grant '{name}' for_command binding is empty; name the command basename (for example 'gh')"
743 )
744 }
745 EnvironmentPolicyError::ForWithoutExpose { name } => {
746 write!(
747 f,
748 "[environment_policy.for_without_expose] grant '{name}' declares for_command without expose_as_env; command binding only applies to an exposed environment variable"
749 )
750 }
751 EnvironmentPolicyError::InvalidForCommand { name, command } => {
752 write!(
753 f,
754 "[environment_policy.invalid_for_command] grant '{name}' for_command '{command}' must be a command basename, not a path"
755 )
756 }
757 EnvironmentPolicyError::MissingEnv { name, var } => write!(
758 f,
759 "[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"
760 ),
761 EnvironmentPolicyError::PolicyForbidsGrants { policy, attempted } => write!(
762 f,
763 "[environment_policy.grants_forbidden] environment policy '{}' forbids grants, but {attempted} were declared; use 'granted' or remove the grants",
764 policy.as_str()
765 ),
766 EnvironmentPolicyError::DuplicateGrant { name } => write!(
767 f,
768 "[environment_policy.duplicate_grant] grant name '{name}' is declared more than once; give every grant a unique name"
769 ),
770 EnvironmentPolicyError::DuplicateExposureTarget { target } => write!(
771 f,
772 "[environment_policy.duplicate_exposure_target] environment target '{target}' is exposed by more than one grant; choose one grant for each target"
773 ),
774 EnvironmentPolicyError::ChildPolicyExceedsParent {
775 parent,
776 requested,
777 offending_grant: _,
778 detail,
779 } => write!(
780 f,
781 "[environment_policy.child_exceeds_parent] child policy '{}' exceeds parent policy '{}': {detail}",
782 requested.as_str(),
783 parent.as_str()
784 ),
785 EnvironmentPolicyError::MissingSecret { name } => {
786 write!(
787 f,
788 "[environment_policy.secret_unavailable] grant '{name}' is unavailable from the secret store; restore access, rotate the reference, or remove the grant"
789 )
790 }
791 }
792 }
793}
794
795impl std::error::Error for EnvironmentPolicyError {}
796
797impl EnvironmentPolicyError {
798 pub fn code(&self) -> &'static str {
800 match self {
801 Self::EmptyName => "environment_policy.empty_grant_name",
802 Self::EmptyEnvVar { .. } => "environment_policy.empty_source_variable",
803 Self::EmptySecretRef { .. } => "environment_policy.empty_secret_reference",
804 Self::EmptyExposeVar { .. } => "environment_policy.empty_exposure_target",
805 Self::EmptyForCommand { .. } => "environment_policy.empty_for_command",
806 Self::ForWithoutExpose { .. } => "environment_policy.for_without_expose",
807 Self::InvalidForCommand { .. } => "environment_policy.invalid_for_command",
808 Self::MissingEnv { .. } => "environment_policy.source_variable_missing",
809 Self::PolicyForbidsGrants { .. } => "environment_policy.grants_forbidden",
810 Self::DuplicateGrant { .. } => "environment_policy.duplicate_grant",
811 Self::DuplicateExposureTarget { .. } => "environment_policy.duplicate_exposure_target",
812 Self::ChildPolicyExceedsParent { .. } => "environment_policy.child_exceeds_parent",
813 Self::MissingSecret { .. } => "environment_policy.secret_unavailable",
814 }
815 }
816
817 pub fn to_json(&self) -> serde_json::Value {
819 let mut value = serde_json::json!({
820 "code": self.code(),
821 "message": self.to_string(),
822 });
823 let object = value
824 .as_object_mut()
825 .expect("environment policy diagnostic is an object");
826 match self {
827 Self::EmptyEnvVar { name }
828 | Self::EmptySecretRef { name }
829 | Self::EmptyExposeVar { name }
830 | Self::EmptyForCommand { name }
831 | Self::ForWithoutExpose { name }
832 | Self::MissingSecret { name } => {
833 object.insert("grant".to_string(), serde_json::json!(name));
834 }
835 Self::InvalidForCommand { name, command } => {
836 object.insert("grant".to_string(), serde_json::json!(name));
837 object.insert("forCommand".to_string(), serde_json::json!(command));
838 }
839 Self::MissingEnv { name, var } => {
840 object.insert("grant".to_string(), serde_json::json!(name));
841 object.insert("sourceVariable".to_string(), serde_json::json!(var));
842 }
843 Self::PolicyForbidsGrants { policy, attempted } => {
844 object.insert("policy".to_string(), serde_json::json!(policy.as_str()));
845 object.insert("attemptedGrants".to_string(), serde_json::json!(attempted));
846 }
847 Self::DuplicateGrant { name } => {
848 object.insert("grant".to_string(), serde_json::json!(name));
849 }
850 Self::DuplicateExposureTarget { target } => {
851 object.insert("target".to_string(), serde_json::json!(target));
852 }
853 Self::ChildPolicyExceedsParent {
854 parent,
855 requested,
856 offending_grant,
857 detail,
858 } => {
859 object.insert(
860 "parentPolicy".to_string(),
861 serde_json::json!(parent.as_str()),
862 );
863 object.insert(
864 "requestedPolicy".to_string(),
865 serde_json::json!(requested.as_str()),
866 );
867 object.insert("detail".to_string(), serde_json::json!(detail));
868 if let Some(grant) = offending_grant {
869 object.insert("grant".to_string(), serde_json::json!(grant));
870 }
871 }
872 Self::EmptyName => {}
873 }
874 value
875 }
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881
882 fn no_env(_: &str) -> Option<String> {
883 None
884 }
885
886 fn env_from(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
887 move |var: &str| {
888 pairs
889 .iter()
890 .find(|(name, _)| *name == var)
891 .map(|(_, value)| value.to_string())
892 }
893 }
894
895 fn env_grant(name: &str, var: &str, expose: Option<&str>) -> GrantSpec {
896 GrantSpec {
897 name: name.to_string(),
898 source: GrantSourceSpec::Env {
899 var: var.to_string(),
900 },
901 expose_as_env: expose.map(str::to_string),
902 for_command: None,
903 }
904 }
905
906 fn secret_grant(name: &str, account: &str, key: &str, expose: Option<&str>) -> GrantSpec {
907 GrantSpec {
908 name: name.to_string(),
909 source: GrantSourceSpec::SecretStore {
910 account: account.to_string(),
911 key: key.to_string(),
912 },
913 expose_as_env: expose.map(str::to_string),
914 for_command: None,
915 }
916 }
917
918 fn command_grant(
919 name: &str,
920 account: &str,
921 key: &str,
922 expose: &str,
923 for_command: &str,
924 ) -> GrantSpec {
925 GrantSpec {
926 name: name.to_string(),
927 source: GrantSourceSpec::SecretStore {
928 account: account.to_string(),
929 key: key.to_string(),
930 },
931 expose_as_env: Some(expose.to_string()),
932 for_command: Some(for_command.to_string()),
933 }
934 }
935
936 #[test]
937 fn isolated_rejects_any_grant_at_launch() {
938 let specs = vec![secret_grant("gh_token", "gh", "token", None)];
939 let err = SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, specs, &no_env)
940 .expect_err("isolated must reject grants");
941 assert_eq!(
942 err,
943 EnvironmentPolicyError::PolicyForbidsGrants {
944 policy: EnvironmentPolicyKind::Isolated,
945 attempted: 1
946 }
947 );
948
949 let environment =
952 SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, vec![], &no_env).unwrap();
953 assert!(environment.is_isolated());
954 assert!(environment.grants().is_empty());
955 assert!(environment.receipts().is_empty());
956 assert!(SessionEnvironment::isolated().grants().is_empty());
957 assert_eq!(
958 EnvironmentPolicyKind::default(),
959 EnvironmentPolicyKind::Inherited
960 );
961 }
962
963 #[test]
964 fn granted_policy_resolves_once_into_typed_record() {
965 let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
966 let specs = vec![
967 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
968 secret_grant("gh_token", "gh", "token", Some("GH_TOKEN")),
969 ];
970 let environment =
971 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
972
973 let grants = environment.grants();
974 assert_eq!(grants.len(), 2);
975 assert_eq!(grants[0].name(), "fireworks");
977 assert_eq!(grants[0].source_kind(), GrantSource::Env);
978 assert_eq!(grants[0].exposed_env_var(), Some("FIREWORKS_API_KEY"));
979 assert_eq!(grants[1].name(), "gh_token");
980 assert_eq!(grants[1].source_kind(), GrantSource::SecretStore);
981 assert_eq!(grants[1].exposed_env_var(), Some("GH_TOKEN"));
982
983 let resolve_secret = |account: &str, key: &str| -> Option<String> {
986 (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
987 };
988 let mut pairs = environment.env_exposure(&resolve_secret).unwrap();
989 pairs.sort();
990 assert_eq!(
991 pairs,
992 vec![
993 (
994 "FIREWORKS_API_KEY".to_string(),
995 "fw-secret-value".to_string()
996 ),
997 ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
998 ]
999 );
1000 }
1001
1002 #[test]
1003 fn secret_pointer_is_not_resolved_at_launch() {
1004 let specs = vec![secret_grant("gh_token", "gh", "token", None)];
1008 let environment =
1009 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env).unwrap();
1010 let never = |_: &str, _: &str| -> Option<String> {
1011 panic!("secret resolver must not run for an unexposed grant")
1012 };
1013 assert!(environment.env_exposure(&never).unwrap().is_empty());
1014 }
1015
1016 #[test]
1017 fn env_grant_snapshots_value_at_launch() {
1018 let at_launch = env_from(&[("TOKEN", "live-at-launch")]);
1021 let specs = vec![env_grant("t", "TOKEN", Some("TOKEN"))];
1022 let environment =
1023 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &at_launch).unwrap();
1024
1025 let never_secret = |_: &str, _: &str| -> Option<String> { None };
1026 let pairs = environment.env_exposure(&never_secret).unwrap();
1027 assert_eq!(
1028 pairs,
1029 vec![("TOKEN".to_string(), "live-at-launch".to_string())]
1030 );
1031 assert_eq!(
1034 environment.env_exposure(&never_secret).unwrap(),
1035 vec![("TOKEN".to_string(), "live-at-launch".to_string())]
1036 );
1037 }
1038
1039 #[test]
1040 fn restricted_policies_do_not_retain_unrelated_launcher_values() {
1041 let snapshot = BTreeMap::from([
1042 ("PATH".to_string(), "/bin".to_string()),
1043 (
1044 "UNRELATED_SECRET".to_string(),
1045 "must-not-be-retained".to_string(),
1046 ),
1047 ]);
1048 let granted = SessionEnvironment::launch_from_snapshot(
1049 EnvironmentPolicyKind::Granted,
1050 Vec::new(),
1051 snapshot,
1052 &no_env,
1053 )
1054 .unwrap();
1055 assert_eq!(granted.launcher_value("PATH"), Some("/bin"));
1056 assert_eq!(granted.launcher_value("UNRELATED_SECRET"), None);
1057 }
1058
1059 #[test]
1060 fn receipts_record_shape_and_never_the_value() {
1061 let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
1062 let specs = vec![
1063 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1064 secret_grant("gh_token", "gh", "token", None),
1065 ];
1066 let environment =
1067 SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
1068
1069 let receipts = environment.receipts();
1070 assert_eq!(
1071 receipts,
1072 vec![
1073 GrantReceipt {
1074 name: "fireworks".to_string(),
1075 source_kind: "env".to_string(),
1076 exposed_as_env: Some("FIREWORKS_API_KEY".to_string()),
1077 for_command: None,
1078 },
1079 GrantReceipt {
1080 name: "gh_token".to_string(),
1081 source_kind: "secret_store".to_string(),
1082 exposed_as_env: None,
1083 for_command: None,
1084 },
1085 ]
1086 );
1087
1088 let json = serde_json::to_string(&receipts).unwrap();
1092 assert!(
1093 !json.contains("fw-secret-value"),
1094 "receipt leaked env value"
1095 );
1096 assert!(!json.contains("gh/token"), "receipt leaked secret pointer");
1097 assert!(json.contains("\"source_kind\":\"env\""));
1098 assert!(json.contains("\"source_kind\":\"secret_store\""));
1099 }
1100
1101 #[test]
1102 fn grant_spec_is_value_free_over_the_wire() {
1103 let spec = env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY"));
1106 let json = serde_json::to_string(&spec).unwrap();
1107 let round: GrantSpec = serde_json::from_str(&json).unwrap();
1108 assert_eq!(round, spec);
1109 assert!(json.contains("\"env\""));
1110 assert!(json.contains("FIREWORKS_API_KEY"));
1111
1112 assert_eq!(
1114 serde_json::from_str::<EnvironmentPolicyKind>("\"granted\"").unwrap(),
1115 EnvironmentPolicyKind::Granted
1116 );
1117 assert_eq!(
1118 EnvironmentPolicyKind::default(),
1119 EnvironmentPolicyKind::Inherited
1120 );
1121 }
1122
1123 #[test]
1124 fn missing_env_source_fails_at_launch() {
1125 let specs = vec![env_grant("t", "ABSENT_VAR", None)];
1126 let err = SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env)
1127 .expect_err("absent env var must fail resolution");
1128 assert_eq!(
1129 err,
1130 EnvironmentPolicyError::MissingEnv {
1131 name: "t".to_string(),
1132 var: "ABSENT_VAR".to_string(),
1133 }
1134 );
1135 }
1136
1137 #[test]
1138 fn resolve_rejects_empty_fields() {
1139 let env = env_from(&[("X", "v")]);
1140 assert_eq!(
1141 SessionEnvironment::launch(
1142 EnvironmentPolicyKind::Granted,
1143 vec![env_grant("", "X", None)],
1144 &env
1145 ),
1146 Err(EnvironmentPolicyError::EmptyName)
1147 );
1148 assert_eq!(
1149 SessionEnvironment::launch(
1150 EnvironmentPolicyKind::Granted,
1151 vec![env_grant("t", "", None)],
1152 &env
1153 ),
1154 Err(EnvironmentPolicyError::EmptyEnvVar {
1155 name: "t".to_string()
1156 })
1157 );
1158 assert_eq!(
1159 SessionEnvironment::launch(
1160 EnvironmentPolicyKind::Granted,
1161 vec![secret_grant("t", "acct", "", None)],
1162 &env
1163 ),
1164 Err(EnvironmentPolicyError::EmptySecretRef {
1165 name: "t".to_string()
1166 })
1167 );
1168 assert_eq!(
1169 SessionEnvironment::launch(
1170 EnvironmentPolicyKind::Granted,
1171 vec![env_grant("t", "X", Some(" "))],
1172 &env
1173 ),
1174 Err(EnvironmentPolicyError::EmptyExposeVar {
1175 name: "t".to_string()
1176 })
1177 );
1178 }
1179
1180 #[test]
1181 fn duplicate_names_and_targets_fail_with_stable_codes() {
1182 let env = env_from(&[("A", "a"), ("B", "b")]);
1183 let duplicate_name = SessionEnvironment::launch(
1184 EnvironmentPolicyKind::Granted,
1185 vec![
1186 env_grant("token", "A", Some("A")),
1187 env_grant("token", "B", Some("B")),
1188 ],
1189 &env,
1190 )
1191 .unwrap_err();
1192 assert_eq!(duplicate_name.code(), "environment_policy.duplicate_grant");
1193
1194 let duplicate_target = SessionEnvironment::launch(
1195 EnvironmentPolicyKind::Granted,
1196 vec![
1197 env_grant("a", "A", Some("TOKEN")),
1198 env_grant("b", "B", Some("TOKEN")),
1199 ],
1200 &env,
1201 )
1202 .unwrap_err();
1203 assert_eq!(
1204 duplicate_target.code(),
1205 "environment_policy.duplicate_exposure_target"
1206 );
1207 }
1208
1209 #[test]
1210 fn child_policy_can_only_narrow_parent_authority() {
1211 let snapshot = BTreeMap::from([
1212 ("TOKEN".to_string(), "parent-value".to_string()),
1213 ("PATH".to_string(), "/bin".to_string()),
1214 ]);
1215 let parent = SessionEnvironment::launch_from_snapshot(
1216 EnvironmentPolicyKind::Inherited,
1217 Vec::new(),
1218 snapshot.clone(),
1219 &|name| snapshot.get(name).cloned(),
1220 )
1221 .unwrap();
1222 let child = parent
1223 .narrow(
1224 EnvironmentPolicyKind::Granted,
1225 vec![env_grant("token", "TOKEN", Some("TOKEN"))],
1226 )
1227 .unwrap();
1228 assert_eq!(child.kind(), EnvironmentPolicyKind::Granted);
1229 assert_eq!(child.grants().len(), 1);
1230
1231 let error = child
1232 .narrow(EnvironmentPolicyKind::Inherited, Vec::new())
1233 .unwrap_err();
1234 assert_eq!(error.code(), "environment_policy.child_exceeds_parent");
1235 assert_eq!(error.to_json()["parentPolicy"], "granted");
1236 assert_eq!(error.to_json()["requestedPolicy"], "inherited");
1237
1238 let error = child
1239 .narrow(
1240 EnvironmentPolicyKind::Granted,
1241 vec![env_grant("other", "OTHER_TOKEN", Some("OTHER_TOKEN"))],
1242 )
1243 .unwrap_err();
1244 let diagnostic = error.to_json();
1245 assert_eq!(
1246 diagnostic["code"],
1247 "environment_policy.child_exceeds_parent"
1248 );
1249 assert_eq!(diagnostic["parentPolicy"], "granted");
1250 assert_eq!(diagnostic["requestedPolicy"], "granted");
1251 assert_eq!(diagnostic["grant"], "other");
1252 assert!(diagnostic["message"]
1253 .as_str()
1254 .unwrap()
1255 .contains("unchanged subset of the parent grants"));
1256 }
1257
1258 #[test]
1259 fn command_bound_grant_is_absent_from_session_exposure() {
1260 let resolve_secret = |account: &str, key: &str| -> Option<String> {
1261 (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
1262 };
1263 let environment = SessionEnvironment::launch(
1264 EnvironmentPolicyKind::Granted,
1265 vec![
1266 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1267 command_grant("gh_token", "gh", "token", "GH_TOKEN", "gh"),
1268 ],
1269 &env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]),
1270 )
1271 .unwrap();
1272
1273 let ambient = environment.env_exposure(&resolve_secret).unwrap();
1275 assert_eq!(
1276 ambient,
1277 vec![(
1278 "FIREWORKS_API_KEY".to_string(),
1279 "fw-secret-value".to_string()
1280 )]
1281 );
1282 assert_eq!(
1283 environment
1284 .env_exposure_for("GH_TOKEN", &resolve_secret)
1285 .unwrap(),
1286 None
1287 );
1288
1289 let mut for_gh = environment
1291 .env_exposure_for_command("gh", &resolve_secret)
1292 .unwrap();
1293 for_gh.sort();
1294 assert_eq!(
1295 for_gh,
1296 vec![
1297 (
1298 "FIREWORKS_API_KEY".to_string(),
1299 "fw-secret-value".to_string()
1300 ),
1301 ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
1302 ]
1303 );
1304 let for_git = environment
1305 .env_exposure_for_command("/usr/bin/git", &resolve_secret)
1306 .unwrap();
1307 assert_eq!(
1308 for_git,
1309 vec![(
1310 "FIREWORKS_API_KEY".to_string(),
1311 "fw-secret-value".to_string()
1312 )]
1313 );
1314 assert!(environment
1315 .env_exposure_for_command("/usr/local/bin/gh", &resolve_secret)
1316 .unwrap()
1317 .into_iter()
1318 .any(|(var, _)| var == "GH_TOKEN"));
1319 assert_eq!(command_basename("C:\\Tools\\gh.exe"), "gh");
1320
1321 let receipts = environment.receipts();
1322 assert_eq!(receipts[1].for_command.as_deref(), Some("gh"));
1323 assert_eq!(receipts[1].exposed_as_env.as_deref(), Some("GH_TOKEN"));
1324 }
1325
1326 #[test]
1327 fn for_command_requires_expose_and_rejects_paths() {
1328 let err = SessionEnvironment::launch(
1329 EnvironmentPolicyKind::Granted,
1330 vec![GrantSpec {
1331 name: "gh_token".to_string(),
1332 source: GrantSourceSpec::SecretStore {
1333 account: "gh".to_string(),
1334 key: "token".to_string(),
1335 },
1336 expose_as_env: None,
1337 for_command: Some("gh".to_string()),
1338 }],
1339 &no_env,
1340 )
1341 .unwrap_err();
1342 assert_eq!(err.code(), "environment_policy.for_without_expose");
1343
1344 let err = SessionEnvironment::launch(
1345 EnvironmentPolicyKind::Granted,
1346 vec![GrantSpec {
1347 name: "gh_token".to_string(),
1348 source: GrantSourceSpec::SecretStore {
1349 account: "gh".to_string(),
1350 key: "token".to_string(),
1351 },
1352 expose_as_env: Some("GH_TOKEN".to_string()),
1353 for_command: Some("/usr/bin/gh".to_string()),
1354 }],
1355 &no_env,
1356 )
1357 .unwrap_err();
1358 assert_eq!(err.code(), "environment_policy.invalid_for_command");
1359 }
1360}