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, for_command}) is persisted, and it
29//! omits even the 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    /// When set with [`Self::expose_as_env`], bind the exposure to spawned
105    /// commands whose executable basename matches this value (for example
106    /// `gh` for `/usr/bin/gh`). Omitted means session-scoped exposure:
107    /// `harness.env`, providers, and every spawned command see the variable.
108    /// Command-scoped grants are invisible in-process by construction — harn's
109    /// own `llm_call` is not an exec (harn#5549).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub for_command: Option<String>,
112}
113
114impl GrantSpec {
115    /// Validate the declared shape (non-empty name/fields) and resolve against
116    /// the launcher environment, snapshotting an `env:` source so the child
117    /// never reads the live environment. A `secret_store` source is carried
118    /// through as a pointer, resolved lazily on exposure.
119    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/// The resolved backing of a grant. Private so no consumer can branch on it;
202/// intentionally not `Serialize` so a snapshotted value can never leak into a
203/// record.
204#[derive(Clone, Debug, PartialEq, Eq)]
205enum ResolvedRef {
206    /// A value captured from the launcher env at launch. Held here and never
207    /// re-read from the live environment.
208    EnvSnapshot(String),
209    /// A `secret_store` pointer, resolved to a value only on exposure. Kept as
210    /// a pointer (not a snapshot) so the upstream source stays the single
211    /// source of truth and the grant remains revocable.
212    SecretStore { account: String, key: String },
213}
214
215/// A grant validated and resolved once at the launch boundary. Consumers read
216/// this record; they do not re-branch on `source_kind` or re-check exposure.
217///
218/// Deliberately not `Serialize`: it can hold a snapshotted secret value, so it
219/// must never land in a record. Use [`GrantReceipt`] for anything persisted.
220#[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    /// The grant's logical name used in receipts and diagnostics.
238    pub fn name(&self) -> &str {
239        &self.name
240    }
241
242    /// Where the credential originates.
243    pub fn source_kind(&self) -> GrantSource {
244        self.source_kind
245    }
246
247    /// The process-env variable this grant is exposed as, if any.
248    pub fn exposed_env_var(&self) -> Option<&str> {
249        self.expose_as_env.as_deref()
250    }
251
252    /// The command basename this exposure is bound to, if any.
253    pub fn for_command(&self) -> Option<&str> {
254        self.for_command.as_deref()
255    }
256
257    /// Whether this grant's exposure is ambient to the whole session (no
258    /// `for_command` binding).
259    fn is_session_scoped(&self) -> bool {
260        self.for_command.is_none()
261    }
262
263    /// Whether this grant's exposure applies to a spawn of `program`.
264    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    /// The `(VAR, value)` pair this grant publishes, or `None` when it declared
272    /// no `expose_as_env` target.
273    ///
274    /// This is the single place the source kind is branched on — an
275    /// `EnvSnapshot` is already a value; a `secret_store` pointer is resolved
276    /// here, on use, through the embedder's `resolve_secret` closure. Every
277    /// exposure path goes through this one method, so a consumer never sees the
278    /// source kind and the two paths cannot disagree about what a grant means.
279    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    /// The non-secret receipt for this grant.
299    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
309/// Executable basename used to match a `for_command` grant binding.
310///
311/// `/usr/bin/gh` and `gh` both yield `gh`. Path separators of either style are
312/// recognized so a Windows-style `C:\Tools\gh.exe` still matches `for=gh` when
313/// compared on a Unix host (and vice versa). A trailing `.exe` / `.bat` /
314/// `.cmd` / `.com` suffix is stripped so `gh.exe` matches `for=gh`.
315pub 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/// Which environment policy a session launches under. This is a typed launch
337/// input, not an emergent property of which flags were passed.
338#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "snake_case")]
340pub enum EnvironmentPolicyKind {
341    /// Preserve a launch-time snapshot of the launcher's environment.
342    #[default]
343    Inherited,
344    /// Admit only non-secret runtime essentials. Grants are forbidden.
345    Isolated,
346    /// Admit runtime essentials plus the declared grant set.
347    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/// A launched session's resolved environment.
361///
362/// Not `Serialize` (its grants may hold snapshotted values); serialize
363/// [`SessionEnvironment::receipts`] instead.
364#[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    /// Capture the default policy at a session launch boundary.
373    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    /// Launch an environment from the config's declared grant specs, resolving each
381    /// against the launcher environment.
382    ///
383    /// An isolated policy **rejects any grant at launch** — isolation is an
384    /// enforced structural property, not an assertion made after the fact. A
385    /// granted policy resolves and carries the declared grant set.
386    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    /// Resolve a session from one authoritative launcher snapshot.
401    ///
402    /// `env_lookup` exists for embedders that supply a typed environment
403    /// source. Production callers normally pass a lookup over the same
404    /// snapshot, while tests can provide a small deterministic map.
405    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    /// An isolated environment with no grants.
440    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    /// Whether provider SDKs may use platform-managed ambient discovery such
456    /// as AWS shared config, metadata services, or application-default
457    /// credentials.
458    pub fn allows_implicit_discovery(&self) -> bool {
459        matches!(self.kind, EnvironmentPolicyKind::Inherited)
460    }
461
462    /// Derive a child session without allowing it to gain environment
463    /// authority that its parent did not have.
464    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    /// The resolved grants. Empty for an isolated policy, always.
554    pub fn grants(&self) -> &[SessionGrant] {
555        &self.grants
556    }
557
558    /// The non-secret receipts recorded on the session run-record. Empty for an
559    /// isolated policy, which makes `grants: []` a checked property.
560    pub fn receipts(&self) -> Vec<GrantReceipt> {
561        self.grants.iter().map(SessionGrant::receipt).collect()
562    }
563
564    /// Materialize the session-scoped process environment overlay: the
565    /// `(VAR, value)` pairs for every grant that opted into `expose_as_env`
566    /// without a `for_command` binding. Empty for an isolated policy.
567    ///
568    /// Callers receive uniform pairs and never see the source kind;
569    /// [`SessionGrant::exposure`] owns that branch.
570    ///
571    /// This is the ambient mapping consulted by `harness.env`, providers, and
572    /// the base of every spawned command. Command-bound grants
573    /// (`for_command = Some(...)`) are excluded here and added only by
574    /// [`env_exposure_for_command`](Self::env_exposure_for_command) (harn#5549).
575    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    /// Materialize the environment overlay for one spawn of `program`: every
587    /// session-scoped `expose_as_env` grant, plus every command-bound grant
588    /// whose `for_command` matches [`command_basename`] of `program`.
589    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    /// The value this environment exposes under a single environment variable, or
602    /// `None` if no session-scoped grant targets it.
603    ///
604    /// The narrow counterpart of [`env_exposure`](Self::env_exposure), for a
605    /// consumer resolving one variable — harn's own provider-credential lookup.
606    /// It resolves *only* the grant that targets `var`, which matters for a
607    /// `secret_store` grant: probing an unrelated variable must not reach the
608    /// secret store, and one unresolvable grant must not mask an unrelated
609    /// credential. Launch validation guarantees at most one matching grant.
610    /// Command-bound grants are invisible here — they are not in-process
611    /// credentials.
612    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/// A non-secret record of a grant, safe to persist on a session run-record.
659///
660/// Carries the grant name, source kind, optional environment target, and
661/// optional command binding — never the value or a reversible source
662/// reference. `secret_store` account/key pointers are intentionally omitted.
663#[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/// Errors raised while validating, resolving, or enforcing session grants. All
674/// are launch-boundary failures; none carries a secret value.
675#[derive(Clone, Debug, PartialEq, Eq)]
676pub enum EnvironmentPolicyError {
677    /// A grant spec had an empty name.
678    EmptyName,
679    /// An `env` source named an empty variable.
680    EmptyEnvVar { name: String },
681    /// A `secret_store` source named an empty account or key.
682    EmptySecretRef { name: String },
683    /// An `expose_as_env` target was an empty variable name.
684    EmptyExposeVar { name: String },
685    /// A `for_command` binding was an empty command basename.
686    EmptyForCommand { name: String },
687    /// A `for_command` binding was declared without `expose_as_env`.
688    ForWithoutExpose { name: String },
689    /// A `for_command` binding contained a path separator; only a basename is
690    /// accepted.
691    InvalidForCommand { name: String, command: String },
692    /// An `env` source referenced a variable absent from the launcher env.
693    MissingEnv { name: String, var: String },
694    /// A grant was declared on a policy that does not accept grants.
695    PolicyForbidsGrants {
696        policy: EnvironmentPolicyKind,
697        attempted: usize,
698    },
699    /// More than one grant used the same logical name.
700    DuplicateGrant { name: String },
701    /// More than one grant targeted the same environment variable.
702    DuplicateExposureTarget { target: String },
703    /// A child requested more environment authority than its parent.
704    ChildPolicyExceedsParent {
705        parent: EnvironmentPolicyKind,
706        requested: EnvironmentPolicyKind,
707        offending_grant: Option<String>,
708        detail: String,
709    },
710    /// A `secret_store` grant could not be resolved on exposure.
711    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    /// Stable machine-readable code for CLI, ACP, and host integrations.
799    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    /// Non-secret structured diagnostic for JSON-RPC and machine consumers.
818    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        // Both isolated constructors are structurally empty. The overall
950        // session default remains inherited.
951        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        // Downstream reads the typed record without re-branching on the spec.
976        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        // Exposure materializes uniform (VAR, value) pairs. The secret store
984        // pointer is resolved here, once, through the embedder closure.
985        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        // Resolution of a secret_store grant must not read the value at launch.
1005        // A panicking secret resolver proves exposure is lazy, and an unexposed
1006        // grant never calls the resolver at all.
1007        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        // The launcher env yields "live-at-launch"; the snapshot must hold that
1019        // value afterward — the child never reads the live environment.
1020        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        // The resolved grant is unaffected by any later env — it holds the
1032        // launch-time snapshot.
1033        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        // The serialized receipts must never contain the snapshotted value or
1089        // the secret pointer. (SessionGrant/SessionEnvironment are not Serialize,
1090        // so this is also enforced at compile time; assert it at runtime too.)
1091        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        // A GrantSpec (the config contract) carries the env var NAME and the
1104        // secret pointer, never a value — safe to serialize into a config.
1105        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        // Policy kind is a typed, defaulted config field (inherited by default).
1113        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        // Ambient exposure keeps the provider key and hides the command-bound token.
1274        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        // Only a matching spawn sees GH_TOKEN.
1290        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}