Skip to main content

harn_vm/security/
session_environment.rs

1//! Session-scoped environment policy and grants.
2//!
3//! Every launched session has exactly one policy:
4//!
5//! - [`EnvironmentPolicyKind::Inherited`] preserves a launch-time snapshot.
6//! - [`EnvironmentPolicyKind::Isolated`] admits runtime essentials only.
7//! - [`EnvironmentPolicyKind::Granted`] adds declared grants to those
8//!   essentials.
9//!
10//! A child receives the parent's resolved object and may call
11//! [`SessionEnvironment::narrow`]; it never rereads the ambient host
12//! environment or gains authority its parent did not have.
13//!
14//! # Ownership boundary
15//!
16//! The launcher (e.g. the Burin CLI) parses its own `--grant name=spec`
17//! strings **at its boundary** and hands harn a typed, value-free
18//! [`GrantSpec`] set — carried in the session/ACP config. harn does not parse
19//! flag strings. harn owns the typed contract: [`GrantSpec`] in,
20//! [`SessionGrant`]/[`SessionEnvironment`] resolution, [`GrantReceipt`] schema,
21//! and policy enforcement.
22//!
23//! A [`GrantSpec`] is value-free: an `env:` source names the launcher variable
24//! (not its value); a `secret_store` source is an account/key *pointer*. So a
25//! spec is safe to serialize into a session config. The resolved
26//! [`SessionGrant`] may hold a snapshotted secret value, so it is deliberately
27//! **not** `Serialize` and never lands in a record — only [`GrantReceipt`]
28//! ({name, source_kind, exposed_as_env}) is persisted, and it omits even the
29//! secret pointer.
30//!
31//! Two non-leakage properties are enforced by the type system:
32//!
33//!   * The value-bearing types ([`SessionGrant`], [`SessionEnvironment`], and the
34//!     private `ResolvedRef`) are not `Serialize`. The compiler refuses to
35//!     serialize a type that can hold a secret, so a record cannot leak one.
36//!   * An `env:` source is snapshotted at launch, so the child never reads the
37//!     launcher's live environment; a later env mutation does not change what
38//!     the session sees.
39//!
40//! Materializing a `secret_store` pointer into a value happens through the
41//! embedder's `resolve_secret` closure (backed by the `secret_store` facade),
42//! so this crate takes no dependency on the hostlib that registers it.
43
44use std::collections::{BTreeMap, BTreeSet};
45use std::fmt;
46
47use serde::{Deserialize, Serialize};
48
49/// Where a granted value originates. Recorded in receipts as a stable
50/// string; never carries the value itself.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum GrantSource {
53    /// Snapshotted from a launcher environment variable at launch time.
54    Env,
55    /// A pointer into the `secret_store` facade, resolved on use.
56    SecretStore,
57}
58
59impl GrantSource {
60    /// Stable wire string used in receipts and diagnostics.
61    pub fn as_str(self) -> &'static str {
62        match self {
63            GrantSource::Env => "env",
64            GrantSource::SecretStore => "secret_store",
65        }
66    }
67}
68
69/// The value-free source of a grant, as declared in the session config. An
70/// `Env` source names a launcher variable (not its value); a `SecretStore`
71/// source is an account/key pointer. Both are safe to serialize.
72#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum GrantSourceSpec {
75    /// Snapshot the named launcher environment variable at launch.
76    Env { var: String },
77    /// A `secret_store` account/key pointer, resolved lazily on exposure.
78    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/// A single grant as declared by the launcher in the session config. Typed and
91/// value-free: harn receives this already-structured (the launcher did any
92/// string parsing at its own boundary) and validates/resolves it once, here.
93#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
94pub struct GrantSpec {
95    /// Logical grant name used in receipts and diagnostics.
96    pub name: String,
97    /// Where the credential comes from.
98    pub source: GrantSourceSpec,
99    /// The target process-env variable to expose the value as, if any. `None`
100    /// (the default) means the grant is not exposed to `process.exec`. This is
101    /// the sole place the exposure default lives.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub expose_as_env: Option<String>,
104}
105
106impl GrantSpec {
107    /// Validate the declared shape (non-empty name/fields) and resolve against
108    /// the launcher environment, snapshotting an `env:` source so the child
109    /// never reads the live environment. A `secret_store` source is carried
110    /// through as a pointer, resolved lazily on exposure.
111    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/// The resolved backing of a grant. Private so no consumer can branch on it;
166/// intentionally not `Serialize` so a snapshotted value can never leak into a
167/// record.
168#[derive(Clone, Debug, PartialEq, Eq)]
169enum ResolvedRef {
170    /// A value captured from the launcher env at launch. Held here and never
171    /// re-read from the live environment.
172    EnvSnapshot(String),
173    /// A `secret_store` pointer, resolved to a value only on exposure. Kept as
174    /// a pointer (not a snapshot) so the upstream source stays the single
175    /// source of truth and the grant remains revocable.
176    SecretStore { account: String, key: String },
177}
178
179/// A grant validated and resolved once at the launch boundary. Consumers read
180/// this record; they do not re-branch on `source_kind` or re-check exposure.
181///
182/// Deliberately not `Serialize`: it can hold a snapshotted secret value, so it
183/// must never land in a record. Use [`GrantReceipt`] for anything persisted.
184#[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    /// The grant's logical name used in receipts and diagnostics.
200    pub fn name(&self) -> &str {
201        &self.name
202    }
203
204    /// Where the credential originates.
205    pub fn source_kind(&self) -> GrantSource {
206        self.source_kind
207    }
208
209    /// The process-env variable this grant is exposed as, if any.
210    pub fn exposed_env_var(&self) -> Option<&str> {
211        self.expose_as_env.as_deref()
212    }
213
214    /// The `(VAR, value)` pair this grant publishes, or `None` when it declared
215    /// no `expose_as_env` target.
216    ///
217    /// This is the single place the source kind is branched on — an
218    /// `EnvSnapshot` is already a value; a `secret_store` pointer is resolved
219    /// here, on use, through the embedder's `resolve_secret` closure. Every
220    /// exposure path goes through this one method, so a consumer never sees the
221    /// source kind and the two paths cannot disagree about what a grant means.
222    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    /// The non-secret receipt for this grant.
242    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/// Which environment policy a session launches under. This is a typed launch
252/// input, not an emergent property of which flags were passed.
253#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum EnvironmentPolicyKind {
256    /// Preserve a launch-time snapshot of the launcher's environment.
257    #[default]
258    Inherited,
259    /// Admit only non-secret runtime essentials. Grants are forbidden.
260    Isolated,
261    /// Admit runtime essentials plus the declared grant set.
262    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/// A launched session's resolved environment.
276///
277/// Not `Serialize` (its grants may hold snapshotted values); serialize
278/// [`SessionEnvironment::receipts`] instead.
279#[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    /// Capture the default policy at a session launch boundary.
288    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    /// Launch an environment from the config's declared grant specs, resolving each
296    /// against the launcher environment.
297    ///
298    /// An isolated policy **rejects any grant at launch** — isolation is an
299    /// enforced structural property, not an assertion made after the fact. A
300    /// granted policy resolves and carries the declared grant set.
301    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    /// Resolve a session from one authoritative launcher snapshot.
316    ///
317    /// `env_lookup` exists for embedders that supply a typed environment
318    /// source. Production callers normally pass a lookup over the same
319    /// snapshot, while tests can provide a small deterministic map.
320    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    /// An isolated environment with no grants.
355    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    /// Whether provider SDKs may use platform-managed ambient discovery such
371    /// as AWS shared config, metadata services, or application-default
372    /// credentials.
373    pub fn allows_implicit_discovery(&self) -> bool {
374        matches!(self.kind, EnvironmentPolicyKind::Inherited)
375    }
376
377    /// Derive a child session without allowing it to gain environment
378    /// authority that its parent did not have.
379    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    /// The resolved grants. Empty for an isolated policy, always.
469    pub fn grants(&self) -> &[SessionGrant] {
470        &self.grants
471    }
472
473    /// The non-secret receipts recorded on the session run-record. Empty for an
474    /// isolated policy, which makes `grants: []` a checked property.
475    pub fn receipts(&self) -> Vec<GrantReceipt> {
476        self.grants.iter().map(SessionGrant::receipt).collect()
477    }
478
479    /// Materialize the process environment overlay for `process.exec`: the
480    /// `(VAR, value)` pairs for every grant that opted into `expose_as_env`.
481    /// Empty for an isolated policy.
482    ///
483    /// Callers receive uniform pairs and never see the source kind;
484    /// [`SessionGrant::exposure`] owns that branch.
485    ///
486    /// Exposure is session-wide: `harness.env`, providers, and every spawned
487    /// command consult the same target mapping.
488    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    /// The value this environment exposes under a single environment variable, or
499    /// `None` if no grant targets it.
500    ///
501    /// The narrow counterpart of [`env_exposure`](Self::env_exposure), for a
502    /// consumer resolving one variable — harn's own provider-credential lookup.
503    /// It resolves *only* the grant that targets `var`, which matters for a
504    /// `secret_store` grant: probing an unrelated variable must not reach the
505    /// secret store, and one unresolvable grant must not mask an unrelated
506    /// credential. Launch validation guarantees at most one matching grant.
507    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/// A non-secret record of a grant, safe to persist on a session run-record.
554///
555/// Carries the grant name, source kind, and optional environment target — never
556/// the value or a reversible source reference. `secret_store` account/key
557/// pointers are intentionally omitted.
558#[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/// Errors raised while validating, resolving, or enforcing session grants. All
567/// are launch-boundary failures; none carries a secret value.
568#[derive(Clone, Debug, PartialEq, Eq)]
569pub enum EnvironmentPolicyError {
570    /// A grant spec had an empty name.
571    EmptyName,
572    /// An `env` source named an empty variable.
573    EmptyEnvVar { name: String },
574    /// A `secret_store` source named an empty account or key.
575    EmptySecretRef { name: String },
576    /// An `expose_as_env` target was an empty variable name.
577    EmptyExposeVar { name: String },
578    /// An `env` source referenced a variable absent from the launcher env.
579    MissingEnv { name: String, var: String },
580    /// A grant was declared on a policy that does not accept grants.
581    PolicyForbidsGrants {
582        policy: EnvironmentPolicyKind,
583        attempted: usize,
584    },
585    /// More than one grant used the same logical name.
586    DuplicateGrant { name: String },
587    /// More than one grant targeted the same environment variable.
588    DuplicateExposureTarget { target: String },
589    /// A child requested more environment authority than its parent.
590    ChildPolicyExceedsParent {
591        parent: EnvironmentPolicyKind,
592        requested: EnvironmentPolicyKind,
593        offending_grant: Option<String>,
594        detail: String,
595    },
596    /// A `secret_store` grant could not be resolved on exposure.
597    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    /// Stable machine-readable code for CLI, ACP, and host integrations.
667    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    /// Non-secret structured diagnostic for JSON-RPC and machine consumers.
683    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        // Both isolated constructors are structurally empty. The overall
789        // session default remains inherited.
790        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        // Downstream reads the typed record without re-branching on the spec.
815        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        // Exposure materializes uniform (VAR, value) pairs. The secret store
823        // pointer is resolved here, once, through the embedder closure.
824        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        // Resolution of a secret_store grant must not read the value at launch.
844        // A panicking secret resolver proves exposure is lazy, and an unexposed
845        // grant never calls the resolver at all.
846        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        // The launcher env yields "live-at-launch"; the snapshot must hold that
858        // value afterward — the child never reads the live environment.
859        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        // The resolved grant is unaffected by any later env — it holds the
871        // launch-time snapshot.
872        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        // The serialized receipts must never contain the snapshotted value or
926        // the secret pointer. (SessionGrant/SessionEnvironment are not Serialize,
927        // so this is also enforced at compile time; assert it at runtime too.)
928        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        // A GrantSpec (the config contract) carries the env var NAME and the
941        // secret pointer, never a value — safe to serialize into a config.
942        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        // Policy kind is a typed, defaulted config field (inherited by default).
950        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}