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        let Some(stem_len) = name.len().checked_sub(suffix.len()).filter(|len| *len > 0) else {
328            continue;
329        };
330        if let Some((stem, tail)) = name.split_at_checked(stem_len) {
331            if tail.eq_ignore_ascii_case(suffix) {
332                return stem;
333            }
334        }
335    }
336    name
337}
338
339/// Which environment policy a session launches under. This is a typed launch
340/// input, not an emergent property of which flags were passed.
341#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(rename_all = "snake_case")]
343pub enum EnvironmentPolicyKind {
344    /// Preserve a launch-time snapshot of the launcher's environment.
345    #[default]
346    Inherited,
347    /// Admit only non-secret runtime essentials. Grants are forbidden.
348    Isolated,
349    /// Admit runtime essentials plus the declared grant set.
350    Granted,
351}
352
353impl EnvironmentPolicyKind {
354    pub fn as_str(self) -> &'static str {
355        match self {
356            EnvironmentPolicyKind::Inherited => "inherited",
357            EnvironmentPolicyKind::Isolated => "isolated",
358            EnvironmentPolicyKind::Granted => "granted",
359        }
360    }
361}
362
363/// A launched session's resolved environment.
364///
365/// Not `Serialize` (its grants may hold snapshotted values); serialize
366/// [`SessionEnvironment::receipts`] instead.
367#[derive(Clone, Debug, PartialEq, Eq)]
368pub struct SessionEnvironment {
369    kind: EnvironmentPolicyKind,
370    launcher_snapshot: BTreeMap<String, String>,
371    grants: Vec<SessionGrant>,
372}
373
374impl SessionEnvironment {
375    /// Capture the default policy at a session launch boundary.
376    pub fn inherited() -> Self {
377        Self::launch(EnvironmentPolicyKind::Inherited, Vec::new(), &|name| {
378            std::env::var(name).ok()
379        })
380        .expect("the inherited policy has no fallible grant configuration")
381    }
382
383    /// Launch an environment from the config's declared grant specs, resolving each
384    /// against the launcher environment.
385    ///
386    /// An isolated policy **rejects any grant at launch** — isolation is an
387    /// enforced structural property, not an assertion made after the fact. A
388    /// granted policy resolves and carries the declared grant set.
389    pub fn launch(
390        kind: EnvironmentPolicyKind,
391        specs: Vec<GrantSpec>,
392        env_lookup: &dyn Fn(&str) -> Option<String>,
393    ) -> Result<Self, EnvironmentPolicyError> {
394        let mut launcher_snapshot = capture_process_environment();
395        for name in super::environment_policy::ENV_ALLOWLIST {
396            if let Some(value) = env_lookup(name) {
397                launcher_snapshot.insert((*name).to_string(), value);
398            }
399        }
400        Self::launch_from_snapshot(kind, specs, launcher_snapshot, env_lookup)
401    }
402
403    /// Resolve a session from one authoritative launcher snapshot.
404    ///
405    /// `env_lookup` exists for embedders that supply a typed environment
406    /// source. Production callers normally pass a lookup over the same
407    /// snapshot, while tests can provide a small deterministic map.
408    pub fn launch_from_snapshot(
409        kind: EnvironmentPolicyKind,
410        specs: Vec<GrantSpec>,
411        launcher_snapshot: BTreeMap<String, String>,
412        env_lookup: &dyn Fn(&str) -> Option<String>,
413    ) -> Result<Self, EnvironmentPolicyError> {
414        if !matches!(kind, EnvironmentPolicyKind::Granted) && !specs.is_empty() {
415            return Err(EnvironmentPolicyError::PolicyForbidsGrants {
416                policy: kind,
417                attempted: specs.len(),
418            });
419        }
420        validate_unique_specs(&specs)?;
421        let grants = specs
422            .into_iter()
423            .map(|spec| spec.resolve(env_lookup))
424            .collect::<Result<Vec<_>, _>>()?;
425        let launcher_snapshot = if matches!(kind, EnvironmentPolicyKind::Inherited) {
426            launcher_snapshot
427        } else {
428            launcher_snapshot
429                .into_iter()
430                .filter(|(name, _)| {
431                    super::environment_policy::ENV_ALLOWLIST.contains(&name.as_str())
432                })
433                .collect()
434        };
435        Ok(SessionEnvironment {
436            kind,
437            launcher_snapshot,
438            grants,
439        })
440    }
441
442    /// An isolated environment with no grants.
443    pub fn isolated() -> Self {
444        Self::launch(EnvironmentPolicyKind::Isolated, Vec::new(), &|name| {
445            std::env::var(name).ok()
446        })
447        .expect("the isolated policy has no fallible grant configuration")
448    }
449
450    pub fn kind(&self) -> EnvironmentPolicyKind {
451        self.kind
452    }
453
454    pub fn is_isolated(&self) -> bool {
455        matches!(self.kind, EnvironmentPolicyKind::Isolated)
456    }
457
458    /// Whether provider SDKs may use platform-managed ambient discovery such
459    /// as AWS shared config, metadata services, or application-default
460    /// credentials.
461    pub fn allows_implicit_discovery(&self) -> bool {
462        matches!(self.kind, EnvironmentPolicyKind::Inherited)
463    }
464
465    /// Derive a child session without allowing it to gain environment
466    /// authority that its parent did not have.
467    pub fn narrow(
468        &self,
469        requested: EnvironmentPolicyKind,
470        specs: Vec<GrantSpec>,
471    ) -> Result<Self, EnvironmentPolicyError> {
472        match (self.kind, requested) {
473            (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Inherited)
474                if specs.is_empty() =>
475            {
476                Ok(self.clone())
477            }
478            (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Isolated)
479                if specs.is_empty() =>
480            {
481                Ok(Self {
482                    kind: requested,
483                    launcher_snapshot: self.launcher_snapshot.clone(),
484                    grants: Vec::new(),
485                })
486            }
487            (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Granted) => {
488                if let Some(spec) = specs
489                    .iter()
490                    .find(|spec| matches!(spec.source, GrantSourceSpec::SecretStore { .. }))
491                {
492                    return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
493                        parent: self.kind,
494                        requested,
495                        offending_grant: Some(spec.name.trim().to_string()),
496                        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(),
497                    });
498                }
499                let snapshot = self.launcher_snapshot.clone();
500                Self::launch_from_snapshot(requested, specs, snapshot.clone(), &|name| {
501                    snapshot.get(name).cloned()
502                })
503            }
504            (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Isolated)
505                if specs.is_empty() =>
506            {
507                Ok(Self {
508                    kind: requested,
509                    launcher_snapshot: self.launcher_snapshot.clone(),
510                    grants: Vec::new(),
511                })
512            }
513            (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Granted) => {
514                validate_unique_specs(&specs)?;
515                let mut grants = Vec::with_capacity(specs.len());
516                for spec in &specs {
517                    let Some(grant) = self.grants.iter().find(|grant| grant.matches_spec(spec))
518                    else {
519                        return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
520                            parent: self.kind,
521                            requested,
522                            offending_grant: Some(spec.name.trim().to_string()),
523                            detail: format!(
524                                "grant '{}' is not an unchanged subset of the parent grants",
525                                spec.name.trim()
526                            ),
527                        });
528                    };
529                    grants.push(grant.clone());
530                }
531                Ok(Self {
532                    kind: requested,
533                    launcher_snapshot: self.launcher_snapshot.clone(),
534                    grants,
535                })
536            }
537            _ => Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
538                parent: self.kind,
539                requested,
540                offending_grant: specs.first().map(|spec| spec.name.trim().to_string()),
541                detail:
542                    "a child may keep or reduce its parent's environment access, never widen it"
543                        .to_string(),
544            }),
545        }
546    }
547
548    pub(crate) fn launcher_value(&self, name: &str) -> Option<&str> {
549        self.launcher_snapshot.get(name).map(String::as_str)
550    }
551
552    pub(crate) fn launcher_snapshot(&self) -> &BTreeMap<String, String> {
553        &self.launcher_snapshot
554    }
555
556    /// The resolved grants. Empty for an isolated policy, always.
557    pub fn grants(&self) -> &[SessionGrant] {
558        &self.grants
559    }
560
561    /// The non-secret receipts recorded on the session run-record. Empty for an
562    /// isolated policy, which makes `grants: []` a checked property.
563    pub fn receipts(&self) -> Vec<GrantReceipt> {
564        self.grants.iter().map(SessionGrant::receipt).collect()
565    }
566
567    /// Materialize the session-scoped process environment overlay: the
568    /// `(VAR, value)` pairs for every grant that opted into `expose_as_env`
569    /// without a `for_command` binding. Empty for an isolated policy.
570    ///
571    /// Callers receive uniform pairs and never see the source kind;
572    /// [`SessionGrant::exposure`] owns that branch.
573    ///
574    /// This is the ambient mapping consulted by `harness.env`, providers, and
575    /// the base of every spawned command. Command-bound grants
576    /// (`for_command = Some(...)`) are excluded here and added only by
577    /// [`env_exposure_for_command`](Self::env_exposure_for_command) (harn#5549).
578    pub fn env_exposure(
579        &self,
580        resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
581    ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
582        self.grants
583            .iter()
584            .filter(|grant| grant.is_session_scoped())
585            .filter_map(|grant| grant.exposure(resolve_secret))
586            .collect()
587    }
588
589    /// Materialize the environment overlay for one spawn of `program`: every
590    /// session-scoped `expose_as_env` grant, plus every command-bound grant
591    /// whose `for_command` matches [`command_basename`] of `program`.
592    pub fn env_exposure_for_command(
593        &self,
594        program: &str,
595        resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
596    ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
597        self.grants
598            .iter()
599            .filter(|grant| grant.applies_to_program(program))
600            .filter_map(|grant| grant.exposure(resolve_secret))
601            .collect()
602    }
603
604    /// The value this environment exposes under a single environment variable, or
605    /// `None` if no session-scoped grant targets it.
606    ///
607    /// The narrow counterpart of [`env_exposure`](Self::env_exposure), for a
608    /// consumer resolving one variable — harn's own provider-credential lookup.
609    /// It resolves *only* the grant that targets `var`, which matters for a
610    /// `secret_store` grant: probing an unrelated variable must not reach the
611    /// secret store, and one unresolvable grant must not mask an unrelated
612    /// credential. Launch validation guarantees at most one matching grant.
613    /// Command-bound grants are invisible here — they are not in-process
614    /// credentials.
615    pub fn env_exposure_for(
616        &self,
617        var: &str,
618        resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
619    ) -> Result<Option<String>, EnvironmentPolicyError> {
620        let Some(grant) = self
621            .grants
622            .iter()
623            .find(|grant| grant.is_session_scoped() && grant.expose_as_env.as_deref() == Some(var))
624        else {
625            return Ok(None);
626        };
627        grant
628            .exposure(resolve_secret)
629            .transpose()
630            .map(|pair| pair.map(|(_, value)| value))
631    }
632}
633
634fn capture_process_environment() -> BTreeMap<String, String> {
635    std::env::vars_os()
636        .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?)))
637        .collect()
638}
639
640fn validate_unique_specs(specs: &[GrantSpec]) -> Result<(), EnvironmentPolicyError> {
641    let mut names = BTreeSet::new();
642    let mut targets = BTreeSet::new();
643    for spec in specs {
644        let name = spec.name.trim();
645        if !name.is_empty() && !names.insert(name) {
646            return Err(EnvironmentPolicyError::DuplicateGrant {
647                name: name.to_string(),
648            });
649        }
650        if let Some(target) = spec.expose_as_env.as_deref().map(str::trim) {
651            if !target.is_empty() && !targets.insert(target) {
652                return Err(EnvironmentPolicyError::DuplicateExposureTarget {
653                    target: target.to_string(),
654                });
655            }
656        }
657    }
658    Ok(())
659}
660
661/// A non-secret record of a grant, safe to persist on a session run-record.
662///
663/// Carries the grant name, source kind, optional environment target, and
664/// optional command binding — never the value or a reversible source
665/// reference. `secret_store` account/key pointers are intentionally omitted.
666#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
667pub struct GrantReceipt {
668    pub name: String,
669    pub source_kind: String,
670    #[serde(default, skip_serializing_if = "Option::is_none")]
671    pub exposed_as_env: Option<String>,
672    #[serde(default, skip_serializing_if = "Option::is_none")]
673    pub for_command: Option<String>,
674}
675
676/// Errors raised while validating, resolving, or enforcing session grants. All
677/// are launch-boundary failures; none carries a secret value.
678#[derive(Clone, Debug, PartialEq, Eq)]
679pub enum EnvironmentPolicyError {
680    /// A grant spec had an empty name.
681    EmptyName,
682    /// An `env` source named an empty variable.
683    EmptyEnvVar { name: String },
684    /// A `secret_store` source named an empty account or key.
685    EmptySecretRef { name: String },
686    /// An `expose_as_env` target was an empty variable name.
687    EmptyExposeVar { name: String },
688    /// A `for_command` binding was an empty command basename.
689    EmptyForCommand { name: String },
690    /// A `for_command` binding was declared without `expose_as_env`.
691    ForWithoutExpose { name: String },
692    /// A `for_command` binding contained a path separator; only a basename is
693    /// accepted.
694    InvalidForCommand { name: String, command: String },
695    /// An `env` source referenced a variable absent from the launcher env.
696    MissingEnv { name: String, var: String },
697    /// A grant was declared on a policy that does not accept grants.
698    PolicyForbidsGrants {
699        policy: EnvironmentPolicyKind,
700        attempted: usize,
701    },
702    /// More than one grant used the same logical name.
703    DuplicateGrant { name: String },
704    /// More than one grant targeted the same environment variable.
705    DuplicateExposureTarget { target: String },
706    /// A child requested more environment authority than its parent.
707    ChildPolicyExceedsParent {
708        parent: EnvironmentPolicyKind,
709        requested: EnvironmentPolicyKind,
710        offending_grant: Option<String>,
711        detail: String,
712    },
713    /// A `secret_store` grant could not be resolved on exposure.
714    MissingSecret { name: String },
715}
716
717impl fmt::Display for EnvironmentPolicyError {
718    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
719        match self {
720            EnvironmentPolicyError::EmptyName => write!(
721                f,
722                "[environment_policy.empty_grant_name] grant spec has an empty name"
723            ),
724            EnvironmentPolicyError::EmptyEnvVar { name } => {
725                write!(
726                    f,
727                    "[environment_policy.empty_source_variable] grant '{name}' env source names an empty variable"
728                )
729            }
730            EnvironmentPolicyError::EmptySecretRef { name } => {
731                write!(
732                    f,
733                    "[environment_policy.empty_secret_reference] grant '{name}' secret source names an empty account/key"
734                )
735            }
736            EnvironmentPolicyError::EmptyExposeVar { name } => {
737                write!(
738                    f,
739                    "[environment_policy.empty_exposure_target] grant '{name}' expose target is an empty variable"
740                )
741            }
742            EnvironmentPolicyError::EmptyForCommand { name } => {
743                write!(
744                    f,
745                    "[environment_policy.empty_for_command] grant '{name}' for_command binding is empty; name the command basename (for example 'gh')"
746                )
747            }
748            EnvironmentPolicyError::ForWithoutExpose { name } => {
749                write!(
750                    f,
751                    "[environment_policy.for_without_expose] grant '{name}' declares for_command without expose_as_env; command binding only applies to an exposed environment variable"
752                )
753            }
754            EnvironmentPolicyError::InvalidForCommand { name, command } => {
755                write!(
756                    f,
757                    "[environment_policy.invalid_for_command] grant '{name}' for_command '{command}' must be a command basename, not a path"
758                )
759            }
760            EnvironmentPolicyError::MissingEnv { name, var } => write!(
761                f,
762                "[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"
763            ),
764            EnvironmentPolicyError::PolicyForbidsGrants { policy, attempted } => write!(
765                f,
766                "[environment_policy.grants_forbidden] environment policy '{}' forbids grants, but {attempted} were declared; use 'granted' or remove the grants",
767                policy.as_str()
768            ),
769            EnvironmentPolicyError::DuplicateGrant { name } => write!(
770                f,
771                "[environment_policy.duplicate_grant] grant name '{name}' is declared more than once; give every grant a unique name"
772            ),
773            EnvironmentPolicyError::DuplicateExposureTarget { target } => write!(
774                f,
775                "[environment_policy.duplicate_exposure_target] environment target '{target}' is exposed by more than one grant; choose one grant for each target"
776            ),
777            EnvironmentPolicyError::ChildPolicyExceedsParent {
778                parent,
779                requested,
780                offending_grant: _,
781                detail,
782            } => write!(
783                f,
784                "[environment_policy.child_exceeds_parent] child policy '{}' exceeds parent policy '{}': {detail}",
785                requested.as_str(),
786                parent.as_str()
787            ),
788            EnvironmentPolicyError::MissingSecret { name } => {
789                write!(
790                    f,
791                    "[environment_policy.secret_unavailable] grant '{name}' is unavailable from the secret store; restore access, rotate the reference, or remove the grant"
792                )
793            }
794        }
795    }
796}
797
798impl std::error::Error for EnvironmentPolicyError {}
799
800impl EnvironmentPolicyError {
801    /// Stable machine-readable code for CLI, ACP, and host integrations.
802    pub fn code(&self) -> &'static str {
803        match self {
804            Self::EmptyName => "environment_policy.empty_grant_name",
805            Self::EmptyEnvVar { .. } => "environment_policy.empty_source_variable",
806            Self::EmptySecretRef { .. } => "environment_policy.empty_secret_reference",
807            Self::EmptyExposeVar { .. } => "environment_policy.empty_exposure_target",
808            Self::EmptyForCommand { .. } => "environment_policy.empty_for_command",
809            Self::ForWithoutExpose { .. } => "environment_policy.for_without_expose",
810            Self::InvalidForCommand { .. } => "environment_policy.invalid_for_command",
811            Self::MissingEnv { .. } => "environment_policy.source_variable_missing",
812            Self::PolicyForbidsGrants { .. } => "environment_policy.grants_forbidden",
813            Self::DuplicateGrant { .. } => "environment_policy.duplicate_grant",
814            Self::DuplicateExposureTarget { .. } => "environment_policy.duplicate_exposure_target",
815            Self::ChildPolicyExceedsParent { .. } => "environment_policy.child_exceeds_parent",
816            Self::MissingSecret { .. } => "environment_policy.secret_unavailable",
817        }
818    }
819
820    /// Non-secret structured diagnostic for JSON-RPC and machine consumers.
821    pub fn to_json(&self) -> serde_json::Value {
822        let mut value = serde_json::json!({
823            "code": self.code(),
824            "message": self.to_string(),
825        });
826        let object = value
827            .as_object_mut()
828            .expect("environment policy diagnostic is an object");
829        match self {
830            Self::EmptyEnvVar { name }
831            | Self::EmptySecretRef { name }
832            | Self::EmptyExposeVar { name }
833            | Self::EmptyForCommand { name }
834            | Self::ForWithoutExpose { name }
835            | Self::MissingSecret { name } => {
836                object.insert("grant".to_string(), serde_json::json!(name));
837            }
838            Self::InvalidForCommand { name, command } => {
839                object.insert("grant".to_string(), serde_json::json!(name));
840                object.insert("forCommand".to_string(), serde_json::json!(command));
841            }
842            Self::MissingEnv { name, var } => {
843                object.insert("grant".to_string(), serde_json::json!(name));
844                object.insert("sourceVariable".to_string(), serde_json::json!(var));
845            }
846            Self::PolicyForbidsGrants { policy, attempted } => {
847                object.insert("policy".to_string(), serde_json::json!(policy.as_str()));
848                object.insert("attemptedGrants".to_string(), serde_json::json!(attempted));
849            }
850            Self::DuplicateGrant { name } => {
851                object.insert("grant".to_string(), serde_json::json!(name));
852            }
853            Self::DuplicateExposureTarget { target } => {
854                object.insert("target".to_string(), serde_json::json!(target));
855            }
856            Self::ChildPolicyExceedsParent {
857                parent,
858                requested,
859                offending_grant,
860                detail,
861            } => {
862                object.insert(
863                    "parentPolicy".to_string(),
864                    serde_json::json!(parent.as_str()),
865                );
866                object.insert(
867                    "requestedPolicy".to_string(),
868                    serde_json::json!(requested.as_str()),
869                );
870                object.insert("detail".to_string(), serde_json::json!(detail));
871                if let Some(grant) = offending_grant {
872                    object.insert("grant".to_string(), serde_json::json!(grant));
873                }
874            }
875            Self::EmptyName => {}
876        }
877        value
878    }
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884
885    fn no_env(_: &str) -> Option<String> {
886        None
887    }
888
889    fn env_from(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
890        move |var: &str| {
891            pairs
892                .iter()
893                .find(|(name, _)| *name == var)
894                .map(|(_, value)| value.to_string())
895        }
896    }
897
898    fn env_grant(name: &str, var: &str, expose: Option<&str>) -> GrantSpec {
899        GrantSpec {
900            name: name.to_string(),
901            source: GrantSourceSpec::Env {
902                var: var.to_string(),
903            },
904            expose_as_env: expose.map(str::to_string),
905            for_command: None,
906        }
907    }
908
909    fn secret_grant(name: &str, account: &str, key: &str, expose: Option<&str>) -> GrantSpec {
910        GrantSpec {
911            name: name.to_string(),
912            source: GrantSourceSpec::SecretStore {
913                account: account.to_string(),
914                key: key.to_string(),
915            },
916            expose_as_env: expose.map(str::to_string),
917            for_command: None,
918        }
919    }
920
921    fn command_grant(
922        name: &str,
923        account: &str,
924        key: &str,
925        expose: &str,
926        for_command: &str,
927    ) -> GrantSpec {
928        GrantSpec {
929            name: name.to_string(),
930            source: GrantSourceSpec::SecretStore {
931                account: account.to_string(),
932                key: key.to_string(),
933            },
934            expose_as_env: Some(expose.to_string()),
935            for_command: Some(for_command.to_string()),
936        }
937    }
938
939    #[test]
940    fn isolated_rejects_any_grant_at_launch() {
941        let specs = vec![secret_grant("gh_token", "gh", "token", None)];
942        let err = SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, specs, &no_env)
943            .expect_err("isolated must reject grants");
944        assert_eq!(
945            err,
946            EnvironmentPolicyError::PolicyForbidsGrants {
947                policy: EnvironmentPolicyKind::Isolated,
948                attempted: 1
949            }
950        );
951
952        // Both isolated constructors are structurally empty. The overall
953        // session default remains inherited.
954        let environment =
955            SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, vec![], &no_env).unwrap();
956        assert!(environment.is_isolated());
957        assert!(environment.grants().is_empty());
958        assert!(environment.receipts().is_empty());
959        assert!(SessionEnvironment::isolated().grants().is_empty());
960        assert_eq!(
961            EnvironmentPolicyKind::default(),
962            EnvironmentPolicyKind::Inherited
963        );
964    }
965
966    #[test]
967    fn granted_policy_resolves_once_into_typed_record() {
968        let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
969        let specs = vec![
970            env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
971            secret_grant("gh_token", "gh", "token", Some("GH_TOKEN")),
972        ];
973        let environment =
974            SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
975
976        let grants = environment.grants();
977        assert_eq!(grants.len(), 2);
978        // Downstream reads the typed record without re-branching on the spec.
979        assert_eq!(grants[0].name(), "fireworks");
980        assert_eq!(grants[0].source_kind(), GrantSource::Env);
981        assert_eq!(grants[0].exposed_env_var(), Some("FIREWORKS_API_KEY"));
982        assert_eq!(grants[1].name(), "gh_token");
983        assert_eq!(grants[1].source_kind(), GrantSource::SecretStore);
984        assert_eq!(grants[1].exposed_env_var(), Some("GH_TOKEN"));
985
986        // Exposure materializes uniform (VAR, value) pairs. The secret store
987        // pointer is resolved here, once, through the embedder closure.
988        let resolve_secret = |account: &str, key: &str| -> Option<String> {
989            (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
990        };
991        let mut pairs = environment.env_exposure(&resolve_secret).unwrap();
992        pairs.sort();
993        assert_eq!(
994            pairs,
995            vec![
996                (
997                    "FIREWORKS_API_KEY".to_string(),
998                    "fw-secret-value".to_string()
999                ),
1000                ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
1001            ]
1002        );
1003    }
1004
1005    #[test]
1006    fn secret_pointer_is_not_resolved_at_launch() {
1007        // Resolution of a secret_store grant must not read the value at launch.
1008        // A panicking secret resolver proves exposure is lazy, and an unexposed
1009        // grant never calls the resolver at all.
1010        let specs = vec![secret_grant("gh_token", "gh", "token", None)];
1011        let environment =
1012            SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env).unwrap();
1013        let never = |_: &str, _: &str| -> Option<String> {
1014            panic!("secret resolver must not run for an unexposed grant")
1015        };
1016        assert!(environment.env_exposure(&never).unwrap().is_empty());
1017    }
1018
1019    #[test]
1020    fn env_grant_snapshots_value_at_launch() {
1021        // The launcher env yields "live-at-launch"; the snapshot must hold that
1022        // value afterward — the child never reads the live environment.
1023        let at_launch = env_from(&[("TOKEN", "live-at-launch")]);
1024        let specs = vec![env_grant("t", "TOKEN", Some("TOKEN"))];
1025        let environment =
1026            SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &at_launch).unwrap();
1027
1028        let never_secret = |_: &str, _: &str| -> Option<String> { None };
1029        let pairs = environment.env_exposure(&never_secret).unwrap();
1030        assert_eq!(
1031            pairs,
1032            vec![("TOKEN".to_string(), "live-at-launch".to_string())]
1033        );
1034        // The resolved grant is unaffected by any later env — it holds the
1035        // launch-time snapshot.
1036        assert_eq!(
1037            environment.env_exposure(&never_secret).unwrap(),
1038            vec![("TOKEN".to_string(), "live-at-launch".to_string())]
1039        );
1040    }
1041
1042    #[test]
1043    fn restricted_policies_do_not_retain_unrelated_launcher_values() {
1044        let snapshot = BTreeMap::from([
1045            ("PATH".to_string(), "/bin".to_string()),
1046            (
1047                "UNRELATED_SECRET".to_string(),
1048                "must-not-be-retained".to_string(),
1049            ),
1050        ]);
1051        let granted = SessionEnvironment::launch_from_snapshot(
1052            EnvironmentPolicyKind::Granted,
1053            Vec::new(),
1054            snapshot,
1055            &no_env,
1056        )
1057        .unwrap();
1058        assert_eq!(granted.launcher_value("PATH"), Some("/bin"));
1059        assert_eq!(granted.launcher_value("UNRELATED_SECRET"), None);
1060    }
1061
1062    #[test]
1063    fn receipts_record_shape_and_never_the_value() {
1064        let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
1065        let specs = vec![
1066            env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1067            secret_grant("gh_token", "gh", "token", None),
1068        ];
1069        let environment =
1070            SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
1071
1072        let receipts = environment.receipts();
1073        assert_eq!(
1074            receipts,
1075            vec![
1076                GrantReceipt {
1077                    name: "fireworks".to_string(),
1078                    source_kind: "env".to_string(),
1079                    exposed_as_env: Some("FIREWORKS_API_KEY".to_string()),
1080                    for_command: None,
1081                },
1082                GrantReceipt {
1083                    name: "gh_token".to_string(),
1084                    source_kind: "secret_store".to_string(),
1085                    exposed_as_env: None,
1086                    for_command: None,
1087                },
1088            ]
1089        );
1090
1091        // The serialized receipts must never contain the snapshotted value or
1092        // the secret pointer. (SessionGrant/SessionEnvironment are not Serialize,
1093        // so this is also enforced at compile time; assert it at runtime too.)
1094        let json = serde_json::to_string(&receipts).unwrap();
1095        assert!(
1096            !json.contains("fw-secret-value"),
1097            "receipt leaked env value"
1098        );
1099        assert!(!json.contains("gh/token"), "receipt leaked secret pointer");
1100        assert!(json.contains("\"source_kind\":\"env\""));
1101        assert!(json.contains("\"source_kind\":\"secret_store\""));
1102    }
1103
1104    #[test]
1105    fn grant_spec_is_value_free_over_the_wire() {
1106        // A GrantSpec (the config contract) carries the env var NAME and the
1107        // secret pointer, never a value — safe to serialize into a config.
1108        let spec = env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY"));
1109        let json = serde_json::to_string(&spec).unwrap();
1110        let round: GrantSpec = serde_json::from_str(&json).unwrap();
1111        assert_eq!(round, spec);
1112        assert!(json.contains("\"env\""));
1113        assert!(json.contains("FIREWORKS_API_KEY"));
1114
1115        // Policy kind is a typed, defaulted config field (inherited by default).
1116        assert_eq!(
1117            serde_json::from_str::<EnvironmentPolicyKind>("\"granted\"").unwrap(),
1118            EnvironmentPolicyKind::Granted
1119        );
1120        assert_eq!(
1121            EnvironmentPolicyKind::default(),
1122            EnvironmentPolicyKind::Inherited
1123        );
1124    }
1125
1126    #[test]
1127    fn missing_env_source_fails_at_launch() {
1128        let specs = vec![env_grant("t", "ABSENT_VAR", None)];
1129        let err = SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env)
1130            .expect_err("absent env var must fail resolution");
1131        assert_eq!(
1132            err,
1133            EnvironmentPolicyError::MissingEnv {
1134                name: "t".to_string(),
1135                var: "ABSENT_VAR".to_string(),
1136            }
1137        );
1138    }
1139
1140    #[test]
1141    fn resolve_rejects_empty_fields() {
1142        let env = env_from(&[("X", "v")]);
1143        assert_eq!(
1144            SessionEnvironment::launch(
1145                EnvironmentPolicyKind::Granted,
1146                vec![env_grant("", "X", None)],
1147                &env
1148            ),
1149            Err(EnvironmentPolicyError::EmptyName)
1150        );
1151        assert_eq!(
1152            SessionEnvironment::launch(
1153                EnvironmentPolicyKind::Granted,
1154                vec![env_grant("t", "", None)],
1155                &env
1156            ),
1157            Err(EnvironmentPolicyError::EmptyEnvVar {
1158                name: "t".to_string()
1159            })
1160        );
1161        assert_eq!(
1162            SessionEnvironment::launch(
1163                EnvironmentPolicyKind::Granted,
1164                vec![secret_grant("t", "acct", "", None)],
1165                &env
1166            ),
1167            Err(EnvironmentPolicyError::EmptySecretRef {
1168                name: "t".to_string()
1169            })
1170        );
1171        assert_eq!(
1172            SessionEnvironment::launch(
1173                EnvironmentPolicyKind::Granted,
1174                vec![env_grant("t", "X", Some(" "))],
1175                &env
1176            ),
1177            Err(EnvironmentPolicyError::EmptyExposeVar {
1178                name: "t".to_string()
1179            })
1180        );
1181    }
1182
1183    #[test]
1184    fn duplicate_names_and_targets_fail_with_stable_codes() {
1185        let env = env_from(&[("A", "a"), ("B", "b")]);
1186        let duplicate_name = SessionEnvironment::launch(
1187            EnvironmentPolicyKind::Granted,
1188            vec![
1189                env_grant("token", "A", Some("A")),
1190                env_grant("token", "B", Some("B")),
1191            ],
1192            &env,
1193        )
1194        .unwrap_err();
1195        assert_eq!(duplicate_name.code(), "environment_policy.duplicate_grant");
1196
1197        let duplicate_target = SessionEnvironment::launch(
1198            EnvironmentPolicyKind::Granted,
1199            vec![
1200                env_grant("a", "A", Some("TOKEN")),
1201                env_grant("b", "B", Some("TOKEN")),
1202            ],
1203            &env,
1204        )
1205        .unwrap_err();
1206        assert_eq!(
1207            duplicate_target.code(),
1208            "environment_policy.duplicate_exposure_target"
1209        );
1210    }
1211
1212    #[test]
1213    fn child_policy_can_only_narrow_parent_authority() {
1214        let snapshot = BTreeMap::from([
1215            ("TOKEN".to_string(), "parent-value".to_string()),
1216            ("PATH".to_string(), "/bin".to_string()),
1217        ]);
1218        let parent = SessionEnvironment::launch_from_snapshot(
1219            EnvironmentPolicyKind::Inherited,
1220            Vec::new(),
1221            snapshot.clone(),
1222            &|name| snapshot.get(name).cloned(),
1223        )
1224        .unwrap();
1225        let child = parent
1226            .narrow(
1227                EnvironmentPolicyKind::Granted,
1228                vec![env_grant("token", "TOKEN", Some("TOKEN"))],
1229            )
1230            .unwrap();
1231        assert_eq!(child.kind(), EnvironmentPolicyKind::Granted);
1232        assert_eq!(child.grants().len(), 1);
1233
1234        let error = child
1235            .narrow(EnvironmentPolicyKind::Inherited, Vec::new())
1236            .unwrap_err();
1237        assert_eq!(error.code(), "environment_policy.child_exceeds_parent");
1238        assert_eq!(error.to_json()["parentPolicy"], "granted");
1239        assert_eq!(error.to_json()["requestedPolicy"], "inherited");
1240
1241        let error = child
1242            .narrow(
1243                EnvironmentPolicyKind::Granted,
1244                vec![env_grant("other", "OTHER_TOKEN", Some("OTHER_TOKEN"))],
1245            )
1246            .unwrap_err();
1247        let diagnostic = error.to_json();
1248        assert_eq!(
1249            diagnostic["code"],
1250            "environment_policy.child_exceeds_parent"
1251        );
1252        assert_eq!(diagnostic["parentPolicy"], "granted");
1253        assert_eq!(diagnostic["requestedPolicy"], "granted");
1254        assert_eq!(diagnostic["grant"], "other");
1255        assert!(diagnostic["message"]
1256            .as_str()
1257            .unwrap()
1258            .contains("unchanged subset of the parent grants"));
1259    }
1260
1261    #[test]
1262    fn command_bound_grant_is_absent_from_session_exposure() {
1263        let resolve_secret = |account: &str, key: &str| -> Option<String> {
1264            (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
1265        };
1266        let environment = SessionEnvironment::launch(
1267            EnvironmentPolicyKind::Granted,
1268            vec![
1269                env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1270                command_grant("gh_token", "gh", "token", "GH_TOKEN", "gh"),
1271            ],
1272            &env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]),
1273        )
1274        .unwrap();
1275
1276        // Ambient exposure keeps the provider key and hides the command-bound token.
1277        let ambient = environment.env_exposure(&resolve_secret).unwrap();
1278        assert_eq!(
1279            ambient,
1280            vec![(
1281                "FIREWORKS_API_KEY".to_string(),
1282                "fw-secret-value".to_string()
1283            )]
1284        );
1285        assert_eq!(
1286            environment
1287                .env_exposure_for("GH_TOKEN", &resolve_secret)
1288                .unwrap(),
1289            None
1290        );
1291
1292        // Only a matching spawn sees GH_TOKEN.
1293        let mut for_gh = environment
1294            .env_exposure_for_command("gh", &resolve_secret)
1295            .unwrap();
1296        for_gh.sort();
1297        assert_eq!(
1298            for_gh,
1299            vec![
1300                (
1301                    "FIREWORKS_API_KEY".to_string(),
1302                    "fw-secret-value".to_string()
1303                ),
1304                ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
1305            ]
1306        );
1307        let for_git = environment
1308            .env_exposure_for_command("/usr/bin/git", &resolve_secret)
1309            .unwrap();
1310        assert_eq!(
1311            for_git,
1312            vec![(
1313                "FIREWORKS_API_KEY".to_string(),
1314                "fw-secret-value".to_string()
1315            )]
1316        );
1317        assert!(environment
1318            .env_exposure_for_command("/usr/local/bin/gh", &resolve_secret)
1319            .unwrap()
1320            .into_iter()
1321            .any(|(var, _)| var == "GH_TOKEN"));
1322        assert_eq!(command_basename("C:\\Tools\\gh.exe"), "gh");
1323
1324        let receipts = environment.receipts();
1325        assert_eq!(receipts[1].for_command.as_deref(), Some("gh"));
1326        assert_eq!(receipts[1].exposed_as_env.as_deref(), Some("GH_TOKEN"));
1327    }
1328
1329    #[test]
1330    fn for_command_requires_expose_and_rejects_paths() {
1331        let err = SessionEnvironment::launch(
1332            EnvironmentPolicyKind::Granted,
1333            vec![GrantSpec {
1334                name: "gh_token".to_string(),
1335                source: GrantSourceSpec::SecretStore {
1336                    account: "gh".to_string(),
1337                    key: "token".to_string(),
1338                },
1339                expose_as_env: None,
1340                for_command: Some("gh".to_string()),
1341            }],
1342            &no_env,
1343        )
1344        .unwrap_err();
1345        assert_eq!(err.code(), "environment_policy.for_without_expose");
1346
1347        let err = SessionEnvironment::launch(
1348            EnvironmentPolicyKind::Granted,
1349            vec![GrantSpec {
1350                name: "gh_token".to_string(),
1351                source: GrantSourceSpec::SecretStore {
1352                    account: "gh".to_string(),
1353                    key: "token".to_string(),
1354                },
1355                expose_as_env: Some("GH_TOKEN".to_string()),
1356                for_command: Some("/usr/bin/gh".to_string()),
1357            }],
1358            &no_env,
1359        )
1360        .unwrap_err();
1361        assert_eq!(err.code(), "environment_policy.invalid_for_command");
1362    }
1363}