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                insert_env_value(&mut launcher_snapshot, name, 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, _)| super::environment_policy::allowlist_admits(name))
431                .collect()
432        };
433        Ok(SessionEnvironment {
434            kind,
435            launcher_snapshot,
436            grants,
437        })
438    }
439
440    /// An isolated environment with no grants.
441    pub fn isolated() -> Self {
442        Self::launch(EnvironmentPolicyKind::Isolated, Vec::new(), &|name| {
443            std::env::var(name).ok()
444        })
445        .expect("the isolated policy has no fallible grant configuration")
446    }
447
448    pub fn kind(&self) -> EnvironmentPolicyKind {
449        self.kind
450    }
451
452    pub fn is_isolated(&self) -> bool {
453        matches!(self.kind, EnvironmentPolicyKind::Isolated)
454    }
455
456    /// Whether provider SDKs may use platform-managed ambient discovery such
457    /// as AWS shared config, metadata services, or application-default
458    /// credentials.
459    pub fn allows_implicit_discovery(&self) -> bool {
460        matches!(self.kind, EnvironmentPolicyKind::Inherited)
461    }
462
463    /// Derive a child session without allowing it to gain environment
464    /// authority that its parent did not have.
465    pub fn narrow(
466        &self,
467        requested: EnvironmentPolicyKind,
468        specs: Vec<GrantSpec>,
469    ) -> Result<Self, EnvironmentPolicyError> {
470        match (self.kind, requested) {
471            (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Inherited)
472                if specs.is_empty() =>
473            {
474                Ok(self.clone())
475            }
476            (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Isolated)
477                if specs.is_empty() =>
478            {
479                Ok(Self {
480                    kind: requested,
481                    launcher_snapshot: self.launcher_snapshot.clone(),
482                    grants: Vec::new(),
483                })
484            }
485            (EnvironmentPolicyKind::Inherited, EnvironmentPolicyKind::Granted) => {
486                if let Some(spec) = specs
487                    .iter()
488                    .find(|spec| matches!(spec.source, GrantSourceSpec::SecretStore { .. }))
489                {
490                    return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
491                        parent: self.kind,
492                        requested,
493                        offending_grant: Some(spec.name.trim().to_string()),
494                        detail: "an inherited parent can grant only values in its launch-time environment snapshot; secret-store authority must be granted to the parent first".to_string(),
495                    });
496                }
497                let snapshot = self.launcher_snapshot.clone();
498                Self::launch_from_snapshot(requested, specs, snapshot.clone(), &|name| {
499                    snapshot.get(name).cloned()
500                })
501            }
502            (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Isolated)
503                if specs.is_empty() =>
504            {
505                Ok(Self {
506                    kind: requested,
507                    launcher_snapshot: self.launcher_snapshot.clone(),
508                    grants: Vec::new(),
509                })
510            }
511            (EnvironmentPolicyKind::Granted, EnvironmentPolicyKind::Granted) => {
512                validate_unique_specs(&specs)?;
513                let mut grants = Vec::with_capacity(specs.len());
514                for spec in &specs {
515                    let Some(grant) = self.grants.iter().find(|grant| grant.matches_spec(spec))
516                    else {
517                        return Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
518                            parent: self.kind,
519                            requested,
520                            offending_grant: Some(spec.name.trim().to_string()),
521                            detail: format!(
522                                "grant '{}' is not an unchanged subset of the parent grants",
523                                spec.name.trim()
524                            ),
525                        });
526                    };
527                    grants.push(grant.clone());
528                }
529                Ok(Self {
530                    kind: requested,
531                    launcher_snapshot: self.launcher_snapshot.clone(),
532                    grants,
533                })
534            }
535            _ => Err(EnvironmentPolicyError::ChildPolicyExceedsParent {
536                parent: self.kind,
537                requested,
538                offending_grant: specs.first().map(|spec| spec.name.trim().to_string()),
539                detail:
540                    "a child may keep or reduce its parent's environment access, never widen it"
541                        .to_string(),
542            }),
543        }
544    }
545
546    /// The launcher's value for `name`, matched the way the host platform
547    /// matches environment names.
548    ///
549    /// Windows environment names are case-insensitive, and the case a parent
550    /// reports is not the case an allowlist is written in: the variable this
551    /// codebase calls `PATH` arrives from a Windows parent spelled `Path`.
552    /// An exact map lookup therefore misses it, and a child inherits no
553    /// search path at all while every name in the allowlist still looks
554    /// admitted. Fold case on Windows so the allowlist means the same thing
555    /// there that it means on POSIX, where names are case-sensitive and the
556    /// exact lookup is the correct one.
557    pub(crate) fn launcher_value(&self, name: &str) -> Option<&str> {
558        if let Some(value) = self.launcher_snapshot.get(name) {
559            return Some(value.as_str());
560        }
561        if cfg!(windows) {
562            return self
563                .launcher_snapshot
564                .iter()
565                .find(|(key, _)| key.eq_ignore_ascii_case(name))
566                .map(|(_, value)| value.as_str());
567        }
568        None
569    }
570
571    pub(crate) fn launcher_snapshot(&self) -> &BTreeMap<String, String> {
572        &self.launcher_snapshot
573    }
574
575    /// The resolved grants. Empty for an isolated policy, always.
576    pub fn grants(&self) -> &[SessionGrant] {
577        &self.grants
578    }
579
580    /// The non-secret receipts recorded on the session run-record. Empty for an
581    /// isolated policy, which makes `grants: []` a checked property.
582    pub fn receipts(&self) -> Vec<GrantReceipt> {
583        self.grants.iter().map(SessionGrant::receipt).collect()
584    }
585
586    /// Materialize the session-scoped process environment overlay: the
587    /// `(VAR, value)` pairs for every grant that opted into `expose_as_env`
588    /// without a `for_command` binding. Empty for an isolated policy.
589    ///
590    /// Callers receive uniform pairs and never see the source kind;
591    /// [`SessionGrant::exposure`] owns that branch.
592    ///
593    /// This is the ambient mapping consulted by `harness.env`, providers, and
594    /// the base of every spawned command. Command-bound grants
595    /// (`for_command = Some(...)`) are excluded here and added only by
596    /// [`env_exposure_for_command`](Self::env_exposure_for_command) (harn#5549).
597    pub fn env_exposure(
598        &self,
599        resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
600    ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
601        self.grants
602            .iter()
603            .filter(|grant| grant.is_session_scoped())
604            .filter_map(|grant| grant.exposure(resolve_secret))
605            .collect()
606    }
607
608    /// Materialize the environment overlay for one spawn of `program`: every
609    /// session-scoped `expose_as_env` grant, plus every command-bound grant
610    /// whose `for_command` matches [`command_basename`] of `program`.
611    pub fn env_exposure_for_command(
612        &self,
613        program: &str,
614        resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
615    ) -> Result<Vec<(String, String)>, EnvironmentPolicyError> {
616        self.grants
617            .iter()
618            .filter(|grant| grant.applies_to_program(program))
619            .filter_map(|grant| grant.exposure(resolve_secret))
620            .collect()
621    }
622
623    /// The value this environment exposes under a single environment variable, or
624    /// `None` if no session-scoped grant targets it.
625    ///
626    /// The narrow counterpart of [`env_exposure`](Self::env_exposure), for a
627    /// consumer resolving one variable — harn's own provider-credential lookup.
628    /// It resolves *only* the grant that targets `var`, which matters for a
629    /// `secret_store` grant: probing an unrelated variable must not reach the
630    /// secret store, and one unresolvable grant must not mask an unrelated
631    /// credential. Launch validation guarantees at most one matching grant.
632    /// Command-bound grants are invisible here — they are not in-process
633    /// credentials.
634    pub fn env_exposure_for(
635        &self,
636        var: &str,
637        resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
638    ) -> Result<Option<String>, EnvironmentPolicyError> {
639        let Some(grant) = self
640            .grants
641            .iter()
642            .find(|grant| grant.is_session_scoped() && grant.expose_as_env.as_deref() == Some(var))
643        else {
644            return Ok(None);
645        };
646        grant
647            .exposure(resolve_secret)
648            .transpose()
649            .map(|pair| pair.map(|(_, value)| value))
650    }
651}
652
653fn capture_process_environment() -> BTreeMap<String, String> {
654    std::env::vars_os()
655        .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?)))
656        .collect()
657}
658
659/// Insert `name=value` the way Windows would: a name that exists under
660/// different casing (`capture_process_environment` seeds OS casing `Path`;
661/// `ENV_ALLOWLIST`'s re-assert loop right after it uses POSIX casing
662/// `PATH`) is updated in place, not duplicated. `Inherited` hands this map
663/// to a child verbatim, so an un-deduped snapshot leaks the duplicate.
664fn insert_env_value_case_insensitive(
665    map: &mut BTreeMap<String, String>,
666    name: &str,
667    value: String,
668) {
669    let existing_key = map
670        .keys()
671        .find(|key| key.eq_ignore_ascii_case(name))
672        .cloned();
673    map.insert(existing_key.unwrap_or_else(|| name.to_string()), value);
674}
675
676/// Case-insensitive on Windows, plain elsewhere: POSIX treats `PATH` and
677/// `Path` as unrelated variables, so folding them there is the bug.
678fn insert_env_value(map: &mut BTreeMap<String, String>, name: &str, value: String) {
679    if cfg!(windows) {
680        insert_env_value_case_insensitive(map, name, value);
681    } else {
682        map.insert(name.to_string(), value);
683    }
684}
685
686fn validate_unique_specs(specs: &[GrantSpec]) -> Result<(), EnvironmentPolicyError> {
687    let mut names = BTreeSet::new();
688    let mut targets = BTreeSet::new();
689    for spec in specs {
690        let name = spec.name.trim();
691        if !name.is_empty() && !names.insert(name) {
692            return Err(EnvironmentPolicyError::DuplicateGrant {
693                name: name.to_string(),
694            });
695        }
696        if let Some(target) = spec.expose_as_env.as_deref().map(str::trim) {
697            if !target.is_empty() && !targets.insert(target) {
698                return Err(EnvironmentPolicyError::DuplicateExposureTarget {
699                    target: target.to_string(),
700                });
701            }
702        }
703    }
704    Ok(())
705}
706
707/// A non-secret record of a grant, safe to persist on a session run-record.
708///
709/// Carries the grant name, source kind, optional environment target, and
710/// optional command binding — never the value or a reversible source
711/// reference. `secret_store` account/key pointers are intentionally omitted.
712#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
713pub struct GrantReceipt {
714    pub name: String,
715    pub source_kind: String,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub exposed_as_env: Option<String>,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub for_command: Option<String>,
720}
721
722/// Errors raised while validating, resolving, or enforcing session grants. All
723/// are launch-boundary failures; none carries a secret value.
724#[derive(Clone, Debug, PartialEq, Eq)]
725pub enum EnvironmentPolicyError {
726    /// A grant spec had an empty name.
727    EmptyName,
728    /// An `env` source named an empty variable.
729    EmptyEnvVar { name: String },
730    /// A `secret_store` source named an empty account or key.
731    EmptySecretRef { name: String },
732    /// An `expose_as_env` target was an empty variable name.
733    EmptyExposeVar { name: String },
734    /// A `for_command` binding was an empty command basename.
735    EmptyForCommand { name: String },
736    /// A `for_command` binding was declared without `expose_as_env`.
737    ForWithoutExpose { name: String },
738    /// A `for_command` binding contained a path separator; only a basename is
739    /// accepted.
740    InvalidForCommand { name: String, command: String },
741    /// An `env` source referenced a variable absent from the launcher env.
742    MissingEnv { name: String, var: String },
743    /// A grant was declared on a policy that does not accept grants.
744    PolicyForbidsGrants {
745        policy: EnvironmentPolicyKind,
746        attempted: usize,
747    },
748    /// More than one grant used the same logical name.
749    DuplicateGrant { name: String },
750    /// More than one grant targeted the same environment variable.
751    DuplicateExposureTarget { target: String },
752    /// A child requested more environment authority than its parent.
753    ChildPolicyExceedsParent {
754        parent: EnvironmentPolicyKind,
755        requested: EnvironmentPolicyKind,
756        offending_grant: Option<String>,
757        detail: String,
758    },
759    /// A `secret_store` grant could not be resolved on exposure.
760    MissingSecret { name: String },
761}
762
763impl fmt::Display for EnvironmentPolicyError {
764    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
765        match self {
766            EnvironmentPolicyError::EmptyName => write!(
767                f,
768                "[environment_policy.empty_grant_name] grant spec has an empty name"
769            ),
770            EnvironmentPolicyError::EmptyEnvVar { name } => {
771                write!(
772                    f,
773                    "[environment_policy.empty_source_variable] grant '{name}' env source names an empty variable"
774                )
775            }
776            EnvironmentPolicyError::EmptySecretRef { name } => {
777                write!(
778                    f,
779                    "[environment_policy.empty_secret_reference] grant '{name}' secret source names an empty account/key"
780                )
781            }
782            EnvironmentPolicyError::EmptyExposeVar { name } => {
783                write!(
784                    f,
785                    "[environment_policy.empty_exposure_target] grant '{name}' expose target is an empty variable"
786                )
787            }
788            EnvironmentPolicyError::EmptyForCommand { name } => {
789                write!(
790                    f,
791                    "[environment_policy.empty_for_command] grant '{name}' for_command binding is empty; name the command basename (for example 'gh')"
792                )
793            }
794            EnvironmentPolicyError::ForWithoutExpose { name } => {
795                write!(
796                    f,
797                    "[environment_policy.for_without_expose] grant '{name}' declares for_command without expose_as_env; command binding only applies to an exposed environment variable"
798                )
799            }
800            EnvironmentPolicyError::InvalidForCommand { name, command } => {
801                write!(
802                    f,
803                    "[environment_policy.invalid_for_command] grant '{name}' for_command '{command}' must be a command basename, not a path"
804                )
805            }
806            EnvironmentPolicyError::MissingEnv { name, var } => write!(
807                f,
808                "[environment_policy.source_variable_missing] grant '{name}' env source variable '{var}' is not set in the launcher environment; set it before launch or choose another source"
809            ),
810            EnvironmentPolicyError::PolicyForbidsGrants { policy, attempted } => write!(
811                f,
812                "[environment_policy.grants_forbidden] environment policy '{}' forbids grants, but {attempted} were declared; use 'granted' or remove the grants",
813                policy.as_str()
814            ),
815            EnvironmentPolicyError::DuplicateGrant { name } => write!(
816                f,
817                "[environment_policy.duplicate_grant] grant name '{name}' is declared more than once; give every grant a unique name"
818            ),
819            EnvironmentPolicyError::DuplicateExposureTarget { target } => write!(
820                f,
821                "[environment_policy.duplicate_exposure_target] environment target '{target}' is exposed by more than one grant; choose one grant for each target"
822            ),
823            EnvironmentPolicyError::ChildPolicyExceedsParent {
824                parent,
825                requested,
826                offending_grant: _,
827                detail,
828            } => write!(
829                f,
830                "[environment_policy.child_exceeds_parent] child policy '{}' exceeds parent policy '{}': {detail}",
831                requested.as_str(),
832                parent.as_str()
833            ),
834            EnvironmentPolicyError::MissingSecret { name } => {
835                write!(
836                    f,
837                    "[environment_policy.secret_unavailable] grant '{name}' is unavailable from the secret store; restore access, rotate the reference, or remove the grant"
838                )
839            }
840        }
841    }
842}
843
844impl std::error::Error for EnvironmentPolicyError {}
845
846impl EnvironmentPolicyError {
847    /// Stable machine-readable code for CLI, ACP, and host integrations.
848    pub fn code(&self) -> &'static str {
849        match self {
850            Self::EmptyName => "environment_policy.empty_grant_name",
851            Self::EmptyEnvVar { .. } => "environment_policy.empty_source_variable",
852            Self::EmptySecretRef { .. } => "environment_policy.empty_secret_reference",
853            Self::EmptyExposeVar { .. } => "environment_policy.empty_exposure_target",
854            Self::EmptyForCommand { .. } => "environment_policy.empty_for_command",
855            Self::ForWithoutExpose { .. } => "environment_policy.for_without_expose",
856            Self::InvalidForCommand { .. } => "environment_policy.invalid_for_command",
857            Self::MissingEnv { .. } => "environment_policy.source_variable_missing",
858            Self::PolicyForbidsGrants { .. } => "environment_policy.grants_forbidden",
859            Self::DuplicateGrant { .. } => "environment_policy.duplicate_grant",
860            Self::DuplicateExposureTarget { .. } => "environment_policy.duplicate_exposure_target",
861            Self::ChildPolicyExceedsParent { .. } => "environment_policy.child_exceeds_parent",
862            Self::MissingSecret { .. } => "environment_policy.secret_unavailable",
863        }
864    }
865
866    /// Non-secret structured diagnostic for JSON-RPC and machine consumers.
867    pub fn to_json(&self) -> serde_json::Value {
868        let mut value = serde_json::json!({
869            "code": self.code(),
870            "message": self.to_string(),
871        });
872        let object = value
873            .as_object_mut()
874            .expect("environment policy diagnostic is an object");
875        match self {
876            Self::EmptyEnvVar { name }
877            | Self::EmptySecretRef { name }
878            | Self::EmptyExposeVar { name }
879            | Self::EmptyForCommand { name }
880            | Self::ForWithoutExpose { name }
881            | Self::MissingSecret { name } => {
882                object.insert("grant".to_string(), serde_json::json!(name));
883            }
884            Self::InvalidForCommand { name, command } => {
885                object.insert("grant".to_string(), serde_json::json!(name));
886                object.insert("forCommand".to_string(), serde_json::json!(command));
887            }
888            Self::MissingEnv { name, var } => {
889                object.insert("grant".to_string(), serde_json::json!(name));
890                object.insert("sourceVariable".to_string(), serde_json::json!(var));
891            }
892            Self::PolicyForbidsGrants { policy, attempted } => {
893                object.insert("policy".to_string(), serde_json::json!(policy.as_str()));
894                object.insert("attemptedGrants".to_string(), serde_json::json!(attempted));
895            }
896            Self::DuplicateGrant { name } => {
897                object.insert("grant".to_string(), serde_json::json!(name));
898            }
899            Self::DuplicateExposureTarget { target } => {
900                object.insert("target".to_string(), serde_json::json!(target));
901            }
902            Self::ChildPolicyExceedsParent {
903                parent,
904                requested,
905                offending_grant,
906                detail,
907            } => {
908                object.insert(
909                    "parentPolicy".to_string(),
910                    serde_json::json!(parent.as_str()),
911                );
912                object.insert(
913                    "requestedPolicy".to_string(),
914                    serde_json::json!(requested.as_str()),
915                );
916                object.insert("detail".to_string(), serde_json::json!(detail));
917                if let Some(grant) = offending_grant {
918                    object.insert("grant".to_string(), serde_json::json!(grant));
919                }
920            }
921            Self::EmptyName => {}
922        }
923        value
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930
931    fn no_env(_: &str) -> Option<String> {
932        None
933    }
934
935    fn env_from(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
936        move |var: &str| {
937            pairs
938                .iter()
939                .find(|(name, _)| *name == var)
940                .map(|(_, value)| value.to_string())
941        }
942    }
943
944    fn env_grant(name: &str, var: &str, expose: Option<&str>) -> GrantSpec {
945        GrantSpec {
946            name: name.to_string(),
947            source: GrantSourceSpec::Env {
948                var: var.to_string(),
949            },
950            expose_as_env: expose.map(str::to_string),
951            for_command: None,
952        }
953    }
954
955    fn secret_grant(name: &str, account: &str, key: &str, expose: Option<&str>) -> GrantSpec {
956        GrantSpec {
957            name: name.to_string(),
958            source: GrantSourceSpec::SecretStore {
959                account: account.to_string(),
960                key: key.to_string(),
961            },
962            expose_as_env: expose.map(str::to_string),
963            for_command: None,
964        }
965    }
966
967    fn command_grant(
968        name: &str,
969        account: &str,
970        key: &str,
971        expose: &str,
972        for_command: &str,
973    ) -> GrantSpec {
974        GrantSpec {
975            name: name.to_string(),
976            source: GrantSourceSpec::SecretStore {
977                account: account.to_string(),
978                key: key.to_string(),
979            },
980            expose_as_env: Some(expose.to_string()),
981            for_command: Some(for_command.to_string()),
982        }
983    }
984
985    #[test]
986    fn isolated_rejects_any_grant_at_launch() {
987        let specs = vec![secret_grant("gh_token", "gh", "token", None)];
988        let err = SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, specs, &no_env)
989            .expect_err("isolated must reject grants");
990        assert_eq!(
991            err,
992            EnvironmentPolicyError::PolicyForbidsGrants {
993                policy: EnvironmentPolicyKind::Isolated,
994                attempted: 1
995            }
996        );
997
998        // Both isolated constructors are structurally empty. The overall
999        // session default remains inherited.
1000        let environment =
1001            SessionEnvironment::launch(EnvironmentPolicyKind::Isolated, vec![], &no_env).unwrap();
1002        assert!(environment.is_isolated());
1003        assert!(environment.grants().is_empty());
1004        assert!(environment.receipts().is_empty());
1005        assert!(SessionEnvironment::isolated().grants().is_empty());
1006        assert_eq!(
1007            EnvironmentPolicyKind::default(),
1008            EnvironmentPolicyKind::Inherited
1009        );
1010    }
1011
1012    #[test]
1013    fn granted_policy_resolves_once_into_typed_record() {
1014        let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
1015        let specs = vec![
1016            env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1017            secret_grant("gh_token", "gh", "token", Some("GH_TOKEN")),
1018        ];
1019        let environment =
1020            SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
1021
1022        let grants = environment.grants();
1023        assert_eq!(grants.len(), 2);
1024        // Downstream reads the typed record without re-branching on the spec.
1025        assert_eq!(grants[0].name(), "fireworks");
1026        assert_eq!(grants[0].source_kind(), GrantSource::Env);
1027        assert_eq!(grants[0].exposed_env_var(), Some("FIREWORKS_API_KEY"));
1028        assert_eq!(grants[1].name(), "gh_token");
1029        assert_eq!(grants[1].source_kind(), GrantSource::SecretStore);
1030        assert_eq!(grants[1].exposed_env_var(), Some("GH_TOKEN"));
1031
1032        // Exposure materializes uniform (VAR, value) pairs. The secret store
1033        // pointer is resolved here, once, through the embedder closure.
1034        let resolve_secret = |account: &str, key: &str| -> Option<String> {
1035            (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
1036        };
1037        let mut pairs = environment.env_exposure(&resolve_secret).unwrap();
1038        pairs.sort();
1039        assert_eq!(
1040            pairs,
1041            vec![
1042                (
1043                    "FIREWORKS_API_KEY".to_string(),
1044                    "fw-secret-value".to_string()
1045                ),
1046                ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
1047            ]
1048        );
1049    }
1050
1051    #[test]
1052    fn secret_pointer_is_not_resolved_at_launch() {
1053        // Resolution of a secret_store grant must not read the value at launch.
1054        // A panicking secret resolver proves exposure is lazy, and an unexposed
1055        // grant never calls the resolver at all.
1056        let specs = vec![secret_grant("gh_token", "gh", "token", None)];
1057        let environment =
1058            SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env).unwrap();
1059        let never = |_: &str, _: &str| -> Option<String> {
1060            panic!("secret resolver must not run for an unexposed grant")
1061        };
1062        assert!(environment.env_exposure(&never).unwrap().is_empty());
1063    }
1064
1065    #[test]
1066    fn env_grant_snapshots_value_at_launch() {
1067        // The launcher env yields "live-at-launch"; the snapshot must hold that
1068        // value afterward — the child never reads the live environment.
1069        let at_launch = env_from(&[("TOKEN", "live-at-launch")]);
1070        let specs = vec![env_grant("t", "TOKEN", Some("TOKEN"))];
1071        let environment =
1072            SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &at_launch).unwrap();
1073
1074        let never_secret = |_: &str, _: &str| -> Option<String> { None };
1075        let pairs = environment.env_exposure(&never_secret).unwrap();
1076        assert_eq!(
1077            pairs,
1078            vec![("TOKEN".to_string(), "live-at-launch".to_string())]
1079        );
1080        // The resolved grant is unaffected by any later env — it holds the
1081        // launch-time snapshot.
1082        assert_eq!(
1083            environment.env_exposure(&never_secret).unwrap(),
1084            vec![("TOKEN".to_string(), "live-at-launch".to_string())]
1085        );
1086    }
1087
1088    #[test]
1089    fn restricted_policies_do_not_retain_unrelated_launcher_values() {
1090        let snapshot = BTreeMap::from([
1091            ("PATH".to_string(), "/bin".to_string()),
1092            (
1093                "UNRELATED_SECRET".to_string(),
1094                "must-not-be-retained".to_string(),
1095            ),
1096        ]);
1097        let granted = SessionEnvironment::launch_from_snapshot(
1098            EnvironmentPolicyKind::Granted,
1099            Vec::new(),
1100            snapshot,
1101            &no_env,
1102        )
1103        .unwrap();
1104        assert_eq!(granted.launcher_value("PATH"), Some("/bin"));
1105        assert_eq!(granted.launcher_value("UNRELATED_SECRET"), None);
1106    }
1107
1108    #[test]
1109    fn receipts_record_shape_and_never_the_value() {
1110        let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
1111        let specs = vec![
1112            env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1113            secret_grant("gh_token", "gh", "token", None),
1114        ];
1115        let environment =
1116            SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &env).unwrap();
1117
1118        let receipts = environment.receipts();
1119        assert_eq!(
1120            receipts,
1121            vec![
1122                GrantReceipt {
1123                    name: "fireworks".to_string(),
1124                    source_kind: "env".to_string(),
1125                    exposed_as_env: Some("FIREWORKS_API_KEY".to_string()),
1126                    for_command: None,
1127                },
1128                GrantReceipt {
1129                    name: "gh_token".to_string(),
1130                    source_kind: "secret_store".to_string(),
1131                    exposed_as_env: None,
1132                    for_command: None,
1133                },
1134            ]
1135        );
1136
1137        // The serialized receipts must never contain the snapshotted value or
1138        // the secret pointer. (SessionGrant/SessionEnvironment are not Serialize,
1139        // so this is also enforced at compile time; assert it at runtime too.)
1140        let json = serde_json::to_string(&receipts).unwrap();
1141        assert!(
1142            !json.contains("fw-secret-value"),
1143            "receipt leaked env value"
1144        );
1145        assert!(!json.contains("gh/token"), "receipt leaked secret pointer");
1146        assert!(json.contains("\"source_kind\":\"env\""));
1147        assert!(json.contains("\"source_kind\":\"secret_store\""));
1148    }
1149
1150    #[test]
1151    fn grant_spec_is_value_free_over_the_wire() {
1152        // A GrantSpec (the config contract) carries the env var NAME and the
1153        // secret pointer, never a value — safe to serialize into a config.
1154        let spec = env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY"));
1155        let json = serde_json::to_string(&spec).unwrap();
1156        let round: GrantSpec = serde_json::from_str(&json).unwrap();
1157        assert_eq!(round, spec);
1158        assert!(json.contains("\"env\""));
1159        assert!(json.contains("FIREWORKS_API_KEY"));
1160
1161        // Policy kind is a typed, defaulted config field (inherited by default).
1162        assert_eq!(
1163            serde_json::from_str::<EnvironmentPolicyKind>("\"granted\"").unwrap(),
1164            EnvironmentPolicyKind::Granted
1165        );
1166        assert_eq!(
1167            EnvironmentPolicyKind::default(),
1168            EnvironmentPolicyKind::Inherited
1169        );
1170    }
1171
1172    #[test]
1173    fn missing_env_source_fails_at_launch() {
1174        let specs = vec![env_grant("t", "ABSENT_VAR", None)];
1175        let err = SessionEnvironment::launch(EnvironmentPolicyKind::Granted, specs, &no_env)
1176            .expect_err("absent env var must fail resolution");
1177        assert_eq!(
1178            err,
1179            EnvironmentPolicyError::MissingEnv {
1180                name: "t".to_string(),
1181                var: "ABSENT_VAR".to_string(),
1182            }
1183        );
1184    }
1185
1186    #[test]
1187    fn resolve_rejects_empty_fields() {
1188        let env = env_from(&[("X", "v")]);
1189        assert_eq!(
1190            SessionEnvironment::launch(
1191                EnvironmentPolicyKind::Granted,
1192                vec![env_grant("", "X", None)],
1193                &env
1194            ),
1195            Err(EnvironmentPolicyError::EmptyName)
1196        );
1197        assert_eq!(
1198            SessionEnvironment::launch(
1199                EnvironmentPolicyKind::Granted,
1200                vec![env_grant("t", "", None)],
1201                &env
1202            ),
1203            Err(EnvironmentPolicyError::EmptyEnvVar {
1204                name: "t".to_string()
1205            })
1206        );
1207        assert_eq!(
1208            SessionEnvironment::launch(
1209                EnvironmentPolicyKind::Granted,
1210                vec![secret_grant("t", "acct", "", None)],
1211                &env
1212            ),
1213            Err(EnvironmentPolicyError::EmptySecretRef {
1214                name: "t".to_string()
1215            })
1216        );
1217        assert_eq!(
1218            SessionEnvironment::launch(
1219                EnvironmentPolicyKind::Granted,
1220                vec![env_grant("t", "X", Some(" "))],
1221                &env
1222            ),
1223            Err(EnvironmentPolicyError::EmptyExposeVar {
1224                name: "t".to_string()
1225            })
1226        );
1227    }
1228
1229    #[test]
1230    fn duplicate_names_and_targets_fail_with_stable_codes() {
1231        let env = env_from(&[("A", "a"), ("B", "b")]);
1232        let duplicate_name = SessionEnvironment::launch(
1233            EnvironmentPolicyKind::Granted,
1234            vec![
1235                env_grant("token", "A", Some("A")),
1236                env_grant("token", "B", Some("B")),
1237            ],
1238            &env,
1239        )
1240        .unwrap_err();
1241        assert_eq!(duplicate_name.code(), "environment_policy.duplicate_grant");
1242
1243        let duplicate_target = SessionEnvironment::launch(
1244            EnvironmentPolicyKind::Granted,
1245            vec![
1246                env_grant("a", "A", Some("TOKEN")),
1247                env_grant("b", "B", Some("TOKEN")),
1248            ],
1249            &env,
1250        )
1251        .unwrap_err();
1252        assert_eq!(
1253            duplicate_target.code(),
1254            "environment_policy.duplicate_exposure_target"
1255        );
1256    }
1257
1258    #[test]
1259    fn child_policy_can_only_narrow_parent_authority() {
1260        let snapshot = BTreeMap::from([
1261            ("TOKEN".to_string(), "parent-value".to_string()),
1262            ("PATH".to_string(), "/bin".to_string()),
1263        ]);
1264        let parent = SessionEnvironment::launch_from_snapshot(
1265            EnvironmentPolicyKind::Inherited,
1266            Vec::new(),
1267            snapshot.clone(),
1268            &|name| snapshot.get(name).cloned(),
1269        )
1270        .unwrap();
1271        let child = parent
1272            .narrow(
1273                EnvironmentPolicyKind::Granted,
1274                vec![env_grant("token", "TOKEN", Some("TOKEN"))],
1275            )
1276            .unwrap();
1277        assert_eq!(child.kind(), EnvironmentPolicyKind::Granted);
1278        assert_eq!(child.grants().len(), 1);
1279
1280        let error = child
1281            .narrow(EnvironmentPolicyKind::Inherited, Vec::new())
1282            .unwrap_err();
1283        assert_eq!(error.code(), "environment_policy.child_exceeds_parent");
1284        assert_eq!(error.to_json()["parentPolicy"], "granted");
1285        assert_eq!(error.to_json()["requestedPolicy"], "inherited");
1286
1287        let error = child
1288            .narrow(
1289                EnvironmentPolicyKind::Granted,
1290                vec![env_grant("other", "OTHER_TOKEN", Some("OTHER_TOKEN"))],
1291            )
1292            .unwrap_err();
1293        let diagnostic = error.to_json();
1294        assert_eq!(
1295            diagnostic["code"],
1296            "environment_policy.child_exceeds_parent"
1297        );
1298        assert_eq!(diagnostic["parentPolicy"], "granted");
1299        assert_eq!(diagnostic["requestedPolicy"], "granted");
1300        assert_eq!(diagnostic["grant"], "other");
1301        assert!(diagnostic["message"]
1302            .as_str()
1303            .unwrap()
1304            .contains("unchanged subset of the parent grants"));
1305    }
1306
1307    #[test]
1308    fn command_bound_grant_is_absent_from_session_exposure() {
1309        let resolve_secret = |account: &str, key: &str| -> Option<String> {
1310            (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
1311        };
1312        let environment = SessionEnvironment::launch(
1313            EnvironmentPolicyKind::Granted,
1314            vec![
1315                env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
1316                command_grant("gh_token", "gh", "token", "GH_TOKEN", "gh"),
1317            ],
1318            &env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]),
1319        )
1320        .unwrap();
1321
1322        // Ambient exposure keeps the provider key and hides the command-bound token.
1323        let ambient = environment.env_exposure(&resolve_secret).unwrap();
1324        assert_eq!(
1325            ambient,
1326            vec![(
1327                "FIREWORKS_API_KEY".to_string(),
1328                "fw-secret-value".to_string()
1329            )]
1330        );
1331        assert_eq!(
1332            environment
1333                .env_exposure_for("GH_TOKEN", &resolve_secret)
1334                .unwrap(),
1335            None
1336        );
1337
1338        // Only a matching spawn sees GH_TOKEN.
1339        let mut for_gh = environment
1340            .env_exposure_for_command("gh", &resolve_secret)
1341            .unwrap();
1342        for_gh.sort();
1343        assert_eq!(
1344            for_gh,
1345            vec![
1346                (
1347                    "FIREWORKS_API_KEY".to_string(),
1348                    "fw-secret-value".to_string()
1349                ),
1350                ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
1351            ]
1352        );
1353        let for_git = environment
1354            .env_exposure_for_command("/usr/bin/git", &resolve_secret)
1355            .unwrap();
1356        assert_eq!(
1357            for_git,
1358            vec![(
1359                "FIREWORKS_API_KEY".to_string(),
1360                "fw-secret-value".to_string()
1361            )]
1362        );
1363        assert!(environment
1364            .env_exposure_for_command("/usr/local/bin/gh", &resolve_secret)
1365            .unwrap()
1366            .into_iter()
1367            .any(|(var, _)| var == "GH_TOKEN"));
1368        assert_eq!(command_basename("C:\\Tools\\gh.exe"), "gh");
1369
1370        let receipts = environment.receipts();
1371        assert_eq!(receipts[1].for_command.as_deref(), Some("gh"));
1372        assert_eq!(receipts[1].exposed_as_env.as_deref(), Some("GH_TOKEN"));
1373    }
1374
1375    #[test]
1376    fn for_command_requires_expose_and_rejects_paths() {
1377        let err = SessionEnvironment::launch(
1378            EnvironmentPolicyKind::Granted,
1379            vec![GrantSpec {
1380                name: "gh_token".to_string(),
1381                source: GrantSourceSpec::SecretStore {
1382                    account: "gh".to_string(),
1383                    key: "token".to_string(),
1384                },
1385                expose_as_env: None,
1386                for_command: Some("gh".to_string()),
1387            }],
1388            &no_env,
1389        )
1390        .unwrap_err();
1391        assert_eq!(err.code(), "environment_policy.for_without_expose");
1392
1393        let err = SessionEnvironment::launch(
1394            EnvironmentPolicyKind::Granted,
1395            vec![GrantSpec {
1396                name: "gh_token".to_string(),
1397                source: GrantSourceSpec::SecretStore {
1398                    account: "gh".to_string(),
1399                    key: "token".to_string(),
1400                },
1401                expose_as_env: Some("GH_TOKEN".to_string()),
1402                for_command: Some("/usr/bin/gh".to_string()),
1403            }],
1404            &no_env,
1405        )
1406        .unwrap_err();
1407        assert_eq!(err.code(), "environment_policy.invalid_for_command");
1408    }
1409
1410    /// Exercised directly, not through `launch` (which reads this process's
1411    /// real `std::env::vars_os`), so it holds on every host.
1412    #[test]
1413    fn a_case_insensitive_insert_updates_the_existing_key_not_a_second_one() {
1414        let mut map = BTreeMap::new();
1415        map.insert(
1416            "Path".to_string(),
1417            "C:\\nodejs;C:\\Windows\\System32".to_string(),
1418        );
1419        insert_env_value_case_insensitive(
1420            &mut map,
1421            "PATH",
1422            "C:\\nodejs;C:\\Windows\\System32;C:\\extra".to_string(),
1423        );
1424        assert_eq!(
1425            map.len(),
1426            1,
1427            "must update the existing 'Path' key, not add a second 'PATH' key: {map:?}"
1428        );
1429        assert_eq!(
1430            map.get("Path").map(String::as_str),
1431            Some("C:\\nodejs;C:\\Windows\\System32;C:\\extra"),
1432            "the original casing is preserved, only the value is refreshed: {map:?}"
1433        );
1434        assert!(
1435            !map.contains_key("PATH"),
1436            "no second key should exist under the allowlist's own casing: {map:?}"
1437        );
1438    }
1439
1440    #[test]
1441    fn a_case_insensitive_insert_adds_a_new_key_when_none_matches() {
1442        let mut map = BTreeMap::new();
1443        insert_env_value_case_insensitive(&mut map, "HOME", "/root".to_string());
1444        assert_eq!(map.get("HOME").map(String::as_str), Some("/root"));
1445    }
1446
1447    /// End-to-end: `launch` -> `resolve_env` -> a real spawned child
1448    /// resolves `node` whenever the parent does (harn#7993). `#[cfg(windows)]`:
1449    /// nothing to prove where OS and allowlist casing cannot diverge.
1450    #[cfg(windows)]
1451    #[test]
1452    fn an_inherited_child_resolves_node_whenever_the_parent_does() {
1453        let parent_has_node = std::process::Command::new("cmd")
1454            .args(["/D", "/C", "where node"])
1455            .output()
1456            .map(|output| output.status.success())
1457            .unwrap_or(false);
1458        if !parent_has_node {
1459            return; // nothing to prove without node on this machine's PATH
1460        }
1461        let environment =
1462            SessionEnvironment::launch(EnvironmentPolicyKind::Inherited, vec![], &no_env)
1463                .expect("inherited launch never fails");
1464        let resolve_secret = |_: &str, _: &str| -> Option<String> { None };
1465        let env = crate::security::resolve_env(&environment, &no_env, &resolve_secret)
1466            .expect("inherited resolve_env never fails");
1467        let path_entries: Vec<(&String, &String)> = env
1468            .iter()
1469            .filter(|(key, _)| key.eq_ignore_ascii_case("PATH"))
1470            .collect();
1471        assert_eq!(
1472            path_entries.len(),
1473            1,
1474            "exactly one PATH-shaped key must reach the child: {:?}",
1475            env.keys().collect::<Vec<_>>()
1476        );
1477        // Byte-for-byte, not just present: Inherited must not rebuild PATH.
1478        let parent_path = std::env::var("PATH").expect("this process has a PATH to compare");
1479        assert_eq!(
1480            path_entries[0].1, &parent_path,
1481            "an Inherited child's PATH must equal the parent's PATH exactly"
1482        );
1483        let output = std::process::Command::new("cmd")
1484            .args(["/D", "/C", "where node"])
1485            .env_clear()
1486            .envs(&env)
1487            .output()
1488            .expect("spawn cmd for the child-side probe");
1489        assert!(
1490            output.status.success(),
1491            "parent resolved node on PATH but an Inherited-policy child did not: \
1492             stdout={:?} stderr={:?}",
1493            String::from_utf8_lossy(&output.stdout),
1494            String::from_utf8_lossy(&output.stderr),
1495        );
1496    }
1497}