Skip to main content

harn_vm/security/
session_grants.rs

1//! Session-scoped capability grants.
2//!
3//! A launched session runs under one of two named profiles that model
4//! opposite credential requirements:
5//!
6//!   * [`SessionProfileKind::Hermetic`] — grants are structurally forbidden.
7//!     `grants: []` is the *runtime definition* of hermetic, enforced at
8//!     launch: attaching any grant to a hermetic profile is a launch error,
9//!     not a warning. This is the identity a replayable, no-credentials run
10//!     (evals) requires, and it stays hermetic by construction rather than by
11//!     an accident of which flags were passed.
12//!   * [`SessionProfileKind::Lane`] — carries a declared set of grants. A
13//!     grant is a scoped, revocable, receipted *pointer* to an upstream
14//!     credential source (the `secret_store` facade, or a launcher env var).
15//!     It is the sole path by which a credential crosses the sandbox
16//!     boundary; nothing else from the launcher's environment or home
17//!     directory reaches the child.
18//!
19//! # Ownership boundary
20//!
21//! The launcher (e.g. the Burin CLI) parses its own `--grant name=spec`
22//! strings **at its boundary** and hands harn a typed, value-free
23//! [`GrantSpec`] set — carried in the session/ACP config. harn does not parse
24//! flag strings. harn owns the typed contract: [`GrantSpec`] in,
25//! [`SessionGrant`]/[`SessionProfile`] resolution, [`GrantReceipt`] schema,
26//! and profile enforcement.
27//!
28//! A [`GrantSpec`] is value-free: an `env:` source names the launcher variable
29//! (not its value); a `secret_store` source is an account/key *pointer*. So a
30//! spec is safe to serialize into a session config. The resolved
31//! [`SessionGrant`] may hold a snapshotted secret value, so it is deliberately
32//! **not** `Serialize` and never lands in a record — only [`GrantReceipt`]
33//! ({name, source_kind, exposed_as_env}) is persisted, and it omits even the
34//! secret pointer.
35//!
36//! Two non-leakage properties are enforced by the type system:
37//!
38//!   * The value-bearing types ([`SessionGrant`], [`SessionProfile`], and the
39//!     private `ResolvedRef`) are not `Serialize`. The compiler refuses to
40//!     serialize a type that can hold a secret, so a record cannot leak one.
41//!   * An `env:` source is snapshotted at launch, so the child never reads the
42//!     launcher's live environment; a later env mutation does not change what
43//!     the session sees.
44//!
45//! Materializing a `secret_store` pointer into a value happens through the
46//! embedder's `resolve_secret` closure (backed by the `secret_store` facade),
47//! so this crate takes no dependency on the hostlib that registers it.
48
49use std::fmt;
50
51use serde::{Deserialize, Serialize};
52
53/// Where a granted credential originates. Recorded in receipts as a stable
54/// string; never carries the value itself.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum GrantSource {
57    /// Snapshotted from a launcher environment variable at launch time.
58    Env,
59    /// A pointer into the `secret_store` facade, resolved on use.
60    SecretStore,
61}
62
63impl GrantSource {
64    /// Stable wire string used in receipts and diagnostics.
65    pub fn as_str(self) -> &'static str {
66        match self {
67            GrantSource::Env => "env",
68            GrantSource::SecretStore => "secret_store",
69        }
70    }
71}
72
73/// The value-free source of a grant, as declared in the session config. An
74/// `Env` source names a launcher variable (not its value); a `SecretStore`
75/// source is an account/key pointer. Both are safe to serialize.
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum GrantSourceSpec {
79    /// Snapshot the named launcher environment variable at launch.
80    Env { var: String },
81    /// A `secret_store` account/key pointer, resolved lazily on exposure.
82    SecretStore { account: String, key: String },
83}
84
85impl GrantSourceSpec {
86    fn kind(&self) -> GrantSource {
87        match self {
88            GrantSourceSpec::Env { .. } => GrantSource::Env,
89            GrantSourceSpec::SecretStore { .. } => GrantSource::SecretStore,
90        }
91    }
92}
93
94/// A single grant as declared by the launcher in the session config. Typed and
95/// value-free: harn receives this already-structured (the launcher did any
96/// string parsing at its own boundary) and validates/resolves it once, here.
97#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
98pub struct GrantSpec {
99    /// Logical grant name (the credential a tool asks for by name).
100    pub name: String,
101    /// Where the credential comes from.
102    pub source: GrantSourceSpec,
103    /// The target process-env variable to expose the value as, if any. `None`
104    /// (the default) means the grant is not exposed to `process.exec`. This is
105    /// the sole place the exposure default lives.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub expose_as_env: Option<String>,
108}
109
110impl GrantSpec {
111    /// Validate the declared shape (non-empty name/fields) and resolve against
112    /// the launcher environment, snapshotting an `env:` source so the child
113    /// never reads the live environment. A `secret_store` source is carried
114    /// through as a pointer, resolved lazily on exposure.
115    fn resolve(
116        self,
117        env_lookup: &dyn Fn(&str) -> Option<String>,
118    ) -> Result<SessionGrant, GrantError> {
119        let name = self.name.trim();
120        if name.is_empty() {
121            return Err(GrantError::EmptyName);
122        }
123        if let Some(var) = self.expose_as_env.as_deref() {
124            if var.trim().is_empty() {
125                return Err(GrantError::EmptyExposeVar {
126                    name: name.to_string(),
127                });
128            }
129        }
130        let source_kind = self.source.kind();
131        let resolved_ref = match self.source {
132            GrantSourceSpec::Env { var } => {
133                let var = var.trim();
134                if var.is_empty() {
135                    return Err(GrantError::EmptyEnvVar {
136                        name: name.to_string(),
137                    });
138                }
139                let value = env_lookup(var).ok_or_else(|| GrantError::MissingEnv {
140                    name: name.to_string(),
141                    var: var.to_string(),
142                })?;
143                ResolvedRef::EnvSnapshot(value)
144            }
145            GrantSourceSpec::SecretStore { account, key } => {
146                let (account, key) = (account.trim(), key.trim());
147                if account.is_empty() || key.is_empty() {
148                    return Err(GrantError::EmptySecretRef {
149                        name: name.to_string(),
150                    });
151                }
152                ResolvedRef::SecretStore {
153                    account: account.to_string(),
154                    key: key.to_string(),
155                }
156            }
157        };
158        Ok(SessionGrant {
159            name: name.to_string(),
160            source_kind,
161            expose_as_env: self.expose_as_env.map(|var| var.trim().to_string()),
162            resolved_ref,
163        })
164    }
165}
166
167/// The resolved backing of a grant. Private so no consumer can branch on it;
168/// intentionally not `Serialize` so a snapshotted value can never leak into a
169/// record.
170#[derive(Clone, Debug, PartialEq, Eq)]
171enum ResolvedRef {
172    /// A value captured from the launcher env at launch. Held here and never
173    /// re-read from the live environment.
174    EnvSnapshot(String),
175    /// A `secret_store` pointer, resolved to a value only on exposure. Kept as
176    /// a pointer (not a snapshot) so the upstream source stays the single
177    /// source of truth and the grant remains revocable.
178    SecretStore { account: String, key: String },
179}
180
181/// A grant validated and resolved once at the launch boundary. Consumers read
182/// this record; they do not re-branch on `source_kind` or re-check exposure.
183///
184/// Deliberately not `Serialize`: it can hold a snapshotted secret value, so it
185/// must never land in a record. Use [`GrantReceipt`] for anything persisted.
186#[derive(Clone, Debug, PartialEq, Eq)]
187pub struct SessionGrant {
188    name: String,
189    source_kind: GrantSource,
190    expose_as_env: Option<String>,
191    resolved_ref: ResolvedRef,
192}
193
194impl SessionGrant {
195    /// The grant's logical name (the credential a tool asks for by name).
196    pub fn name(&self) -> &str {
197        &self.name
198    }
199
200    /// Where the credential originates.
201    pub fn source_kind(&self) -> GrantSource {
202        self.source_kind
203    }
204
205    /// The process-env variable this grant is exposed as, if any.
206    pub fn exposed_env_var(&self) -> Option<&str> {
207        self.expose_as_env.as_deref()
208    }
209
210    /// The non-secret receipt for this grant.
211    pub fn receipt(&self) -> GrantReceipt {
212        GrantReceipt {
213            name: self.name.clone(),
214            source_kind: self.source_kind.as_str().to_string(),
215            exposed_as_env: self.expose_as_env.is_some(),
216        }
217    }
218}
219
220/// Which named profile a session launches under. The profile is a typed launch
221/// input, not an emergent property of which flags were passed.
222#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum SessionProfileKind {
225    /// No credentials may cross the boundary. Grants are forbidden. The
226    /// default: absent an explicit lane profile, a session is hermetic.
227    #[default]
228    Hermetic,
229    /// Autonomous lane: credentials cross only through declared grants.
230    Lane,
231}
232
233impl SessionProfileKind {
234    pub fn as_str(self) -> &'static str {
235        match self {
236            SessionProfileKind::Hermetic => "hermetic",
237            SessionProfileKind::Lane => "lane",
238        }
239    }
240}
241
242/// A launched session's resolved credential profile.
243///
244/// Not `Serialize` (its grants may hold snapshotted values); serialize
245/// [`SessionProfile::receipts`] instead.
246#[derive(Clone, Debug, PartialEq, Eq)]
247pub struct SessionProfile {
248    kind: SessionProfileKind,
249    grants: Vec<SessionGrant>,
250}
251
252impl SessionProfile {
253    /// Launch a profile from the config's declared grant specs, resolving each
254    /// against the launcher environment.
255    ///
256    /// A hermetic profile **rejects any grant at launch** — hermeticity is an
257    /// enforced structural property, not an assertion made after the fact. A
258    /// lane profile resolves and carries the declared grant set.
259    pub fn launch(
260        kind: SessionProfileKind,
261        specs: Vec<GrantSpec>,
262        env_lookup: &dyn Fn(&str) -> Option<String>,
263    ) -> Result<Self, GrantError> {
264        if matches!(kind, SessionProfileKind::Hermetic) && !specs.is_empty() {
265            return Err(GrantError::HermeticForbidsGrants {
266                attempted: specs.len(),
267            });
268        }
269        let grants = specs
270            .into_iter()
271            .map(|spec| spec.resolve(env_lookup))
272            .collect::<Result<Vec<_>, _>>()?;
273        Ok(SessionProfile { kind, grants })
274    }
275
276    /// A hermetic profile with no grants — the runtime definition of hermetic.
277    pub fn hermetic() -> Self {
278        SessionProfile {
279            kind: SessionProfileKind::Hermetic,
280            grants: Vec::new(),
281        }
282    }
283
284    pub fn kind(&self) -> SessionProfileKind {
285        self.kind
286    }
287
288    pub fn is_hermetic(&self) -> bool {
289        matches!(self.kind, SessionProfileKind::Hermetic)
290    }
291
292    /// The resolved grants. Empty for a hermetic profile, always.
293    pub fn grants(&self) -> &[SessionGrant] {
294        &self.grants
295    }
296
297    /// The non-secret receipts recorded on the session run-record. Empty for a
298    /// hermetic profile, which makes `grants: []` a *checked* property.
299    pub fn receipts(&self) -> Vec<GrantReceipt> {
300        self.grants.iter().map(SessionGrant::receipt).collect()
301    }
302
303    /// Materialize the process environment overlay for `process.exec`: the
304    /// `(VAR, value)` pairs for every grant that opted into `expose_as_env`.
305    /// Empty for a hermetic profile.
306    ///
307    /// This is the single place the source kind is branched on — an
308    /// `EnvSnapshot` is already a value; a `secret_store` pointer is resolved
309    /// now through the embedder's `resolve_secret` closure (backed by the
310    /// `secret_store` facade). Callers receive uniform pairs and never see the
311    /// source kind.
312    ///
313    /// # Least privilege
314    ///
315    /// v1 exposes to the session's `process.exec` env. The intended tightening
316    /// is a per-tool binding — the granted var visible only inside the exec
317    /// whose tool declared it, not session-wide ambient. That is a follow-up;
318    /// the ambient scope here is documented, not locked into the contract.
319    pub fn env_exposure(
320        &self,
321        resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
322    ) -> Result<Vec<(String, String)>, GrantError> {
323        let mut pairs = Vec::new();
324        for grant in &self.grants {
325            let Some(var) = grant.expose_as_env.as_ref() else {
326                continue;
327            };
328            let value = match &grant.resolved_ref {
329                ResolvedRef::EnvSnapshot(value) => value.clone(),
330                ResolvedRef::SecretStore { account, key } => resolve_secret(account, key)
331                    .ok_or_else(|| GrantError::MissingSecret {
332                        name: grant.name.clone(),
333                    })?,
334            };
335            pairs.push((var.clone(), value));
336        }
337        Ok(pairs)
338    }
339}
340
341/// A non-secret record of a grant, safe to persist on a session run-record.
342///
343/// Carries the grant name, the source kind, and whether it was exposed to the
344/// process environment — never the value, and never a reversible reference to
345/// it. `secret_store` pointers (account/key) are intentionally omitted.
346#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
347pub struct GrantReceipt {
348    pub name: String,
349    pub source_kind: String,
350    pub exposed_as_env: bool,
351}
352
353/// Errors raised while validating, resolving, or enforcing session grants. All
354/// are launch-boundary failures; none carries a secret value.
355#[derive(Clone, Debug, PartialEq, Eq)]
356pub enum GrantError {
357    /// A grant spec had an empty name.
358    EmptyName,
359    /// An `env` source named an empty variable.
360    EmptyEnvVar { name: String },
361    /// A `secret_store` source named an empty account or key.
362    EmptySecretRef { name: String },
363    /// An `expose_as_env` target was an empty variable name.
364    EmptyExposeVar { name: String },
365    /// An `env` source referenced a variable absent from the launcher env.
366    MissingEnv { name: String, var: String },
367    /// A grant was declared on a hermetic profile.
368    HermeticForbidsGrants { attempted: usize },
369    /// A `secret_store` grant could not be resolved on exposure.
370    MissingSecret { name: String },
371}
372
373impl fmt::Display for GrantError {
374    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375        match self {
376            GrantError::EmptyName => write!(f, "grant spec has an empty name"),
377            GrantError::EmptyEnvVar { name } => {
378                write!(f, "grant '{name}' env source names an empty variable")
379            }
380            GrantError::EmptySecretRef { name } => {
381                write!(f, "grant '{name}' secret source names an empty account/key")
382            }
383            GrantError::EmptyExposeVar { name } => {
384                write!(f, "grant '{name}' expose target is an empty variable")
385            }
386            GrantError::MissingEnv { name, var } => write!(
387                f,
388                "grant '{name}' env source variable '{var}' is not set in the launcher environment"
389            ),
390            GrantError::HermeticForbidsGrants { attempted } => write!(
391                f,
392                "hermetic profile forbids grants, but {attempted} were declared"
393            ),
394            GrantError::MissingSecret { name } => {
395                write!(
396                    f,
397                    "grant '{name}' could not be resolved from the secret store"
398                )
399            }
400        }
401    }
402}
403
404impl std::error::Error for GrantError {}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn no_env(_: &str) -> Option<String> {
411        None
412    }
413
414    fn env_from(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
415        move |var: &str| {
416            pairs
417                .iter()
418                .find(|(name, _)| *name == var)
419                .map(|(_, value)| value.to_string())
420        }
421    }
422
423    fn env_grant(name: &str, var: &str, expose: Option<&str>) -> GrantSpec {
424        GrantSpec {
425            name: name.to_string(),
426            source: GrantSourceSpec::Env {
427                var: var.to_string(),
428            },
429            expose_as_env: expose.map(str::to_string),
430        }
431    }
432
433    fn secret_grant(name: &str, account: &str, key: &str, expose: Option<&str>) -> GrantSpec {
434        GrantSpec {
435            name: name.to_string(),
436            source: GrantSourceSpec::SecretStore {
437                account: account.to_string(),
438                key: key.to_string(),
439            },
440            expose_as_env: expose.map(str::to_string),
441        }
442    }
443
444    #[test]
445    fn hermetic_rejects_any_grant_at_launch() {
446        let specs = vec![secret_grant("gh_token", "gh", "token", None)];
447        let err = SessionProfile::launch(SessionProfileKind::Hermetic, specs, &no_env)
448            .expect_err("hermetic must reject grants");
449        assert_eq!(err, GrantError::HermeticForbidsGrants { attempted: 1 });
450
451        // Belt and suspenders: an empty hermetic launch and the convenience
452        // constructor are both structurally empty, and hermetic is the default.
453        let profile =
454            SessionProfile::launch(SessionProfileKind::Hermetic, vec![], &no_env).unwrap();
455        assert!(profile.is_hermetic());
456        assert!(profile.grants().is_empty());
457        assert!(profile.receipts().is_empty());
458        assert!(SessionProfile::hermetic().grants().is_empty());
459        assert_eq!(SessionProfileKind::default(), SessionProfileKind::Hermetic);
460    }
461
462    #[test]
463    fn lane_grant_resolves_once_into_typed_record() {
464        let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
465        let specs = vec![
466            env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
467            secret_grant("gh_token", "gh", "token", Some("GH_TOKEN")),
468        ];
469        let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &env).unwrap();
470
471        let grants = profile.grants();
472        assert_eq!(grants.len(), 2);
473        // Downstream reads the typed record without re-branching on the spec.
474        assert_eq!(grants[0].name(), "fireworks");
475        assert_eq!(grants[0].source_kind(), GrantSource::Env);
476        assert_eq!(grants[0].exposed_env_var(), Some("FIREWORKS_API_KEY"));
477        assert_eq!(grants[1].name(), "gh_token");
478        assert_eq!(grants[1].source_kind(), GrantSource::SecretStore);
479        assert_eq!(grants[1].exposed_env_var(), Some("GH_TOKEN"));
480
481        // Exposure materializes uniform (VAR, value) pairs. The secret store
482        // pointer is resolved here, once, through the embedder closure.
483        let resolve_secret = |account: &str, key: &str| -> Option<String> {
484            (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
485        };
486        let mut pairs = profile.env_exposure(&resolve_secret).unwrap();
487        pairs.sort();
488        assert_eq!(
489            pairs,
490            vec![
491                (
492                    "FIREWORKS_API_KEY".to_string(),
493                    "fw-secret-value".to_string()
494                ),
495                ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
496            ]
497        );
498    }
499
500    #[test]
501    fn secret_pointer_is_not_resolved_at_launch() {
502        // Resolution of a secret_store grant must not read the value at launch.
503        // A panicking secret resolver proves exposure is lazy, and an unexposed
504        // grant never calls the resolver at all.
505        let specs = vec![secret_grant("gh_token", "gh", "token", None)];
506        let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &no_env).unwrap();
507        let never = |_: &str, _: &str| -> Option<String> {
508            panic!("secret resolver must not run for an unexposed grant")
509        };
510        assert!(profile.env_exposure(&never).unwrap().is_empty());
511    }
512
513    #[test]
514    fn env_grant_snapshots_value_at_launch() {
515        // The launcher env yields "live-at-launch"; the snapshot must hold that
516        // value afterward — the child never reads the live environment.
517        let at_launch = env_from(&[("TOKEN", "live-at-launch")]);
518        let specs = vec![env_grant("t", "TOKEN", Some("TOKEN"))];
519        let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &at_launch).unwrap();
520
521        let never_secret = |_: &str, _: &str| -> Option<String> { None };
522        let pairs = profile.env_exposure(&never_secret).unwrap();
523        assert_eq!(
524            pairs,
525            vec![("TOKEN".to_string(), "live-at-launch".to_string())]
526        );
527        // The resolved grant is unaffected by any later env — it holds the
528        // launch-time snapshot.
529        assert_eq!(
530            profile.env_exposure(&never_secret).unwrap(),
531            vec![("TOKEN".to_string(), "live-at-launch".to_string())]
532        );
533    }
534
535    #[test]
536    fn receipts_record_shape_and_never_the_value() {
537        let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
538        let specs = vec![
539            env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
540            secret_grant("gh_token", "gh", "token", None),
541        ];
542        let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &env).unwrap();
543
544        let receipts = profile.receipts();
545        assert_eq!(
546            receipts,
547            vec![
548                GrantReceipt {
549                    name: "fireworks".to_string(),
550                    source_kind: "env".to_string(),
551                    exposed_as_env: true,
552                },
553                GrantReceipt {
554                    name: "gh_token".to_string(),
555                    source_kind: "secret_store".to_string(),
556                    exposed_as_env: false,
557                },
558            ]
559        );
560
561        // The serialized receipts must never contain the snapshotted value or
562        // the secret pointer. (SessionGrant/SessionProfile are not Serialize,
563        // so this is also enforced at compile time; assert it at runtime too.)
564        let json = serde_json::to_string(&receipts).unwrap();
565        assert!(
566            !json.contains("fw-secret-value"),
567            "receipt leaked env value"
568        );
569        assert!(!json.contains("gh/token"), "receipt leaked secret pointer");
570        assert!(json.contains("\"source_kind\":\"env\""));
571        assert!(json.contains("\"source_kind\":\"secret_store\""));
572    }
573
574    #[test]
575    fn grant_spec_is_value_free_over_the_wire() {
576        // A GrantSpec (the config contract) carries the env var NAME and the
577        // secret pointer, never a value — safe to serialize into a config.
578        let spec = env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY"));
579        let json = serde_json::to_string(&spec).unwrap();
580        let round: GrantSpec = serde_json::from_str(&json).unwrap();
581        assert_eq!(round, spec);
582        assert!(json.contains("\"env\""));
583        assert!(json.contains("FIREWORKS_API_KEY"));
584
585        // Profile kind is a typed, defaulted config field (hermetic by default).
586        assert_eq!(
587            serde_json::from_str::<SessionProfileKind>("\"lane\"").unwrap(),
588            SessionProfileKind::Lane
589        );
590    }
591
592    #[test]
593    fn missing_env_source_fails_at_launch() {
594        let specs = vec![env_grant("t", "ABSENT_VAR", None)];
595        let err = SessionProfile::launch(SessionProfileKind::Lane, specs, &no_env)
596            .expect_err("absent env var must fail resolution");
597        assert_eq!(
598            err,
599            GrantError::MissingEnv {
600                name: "t".to_string(),
601                var: "ABSENT_VAR".to_string(),
602            }
603        );
604    }
605
606    #[test]
607    fn resolve_rejects_empty_fields() {
608        let env = env_from(&[("X", "v")]);
609        assert_eq!(
610            SessionProfile::launch(
611                SessionProfileKind::Lane,
612                vec![env_grant("", "X", None)],
613                &env
614            ),
615            Err(GrantError::EmptyName)
616        );
617        assert_eq!(
618            SessionProfile::launch(
619                SessionProfileKind::Lane,
620                vec![env_grant("t", "", None)],
621                &env
622            ),
623            Err(GrantError::EmptyEnvVar {
624                name: "t".to_string()
625            })
626        );
627        assert_eq!(
628            SessionProfile::launch(
629                SessionProfileKind::Lane,
630                vec![secret_grant("t", "acct", "", None)],
631                &env
632            ),
633            Err(GrantError::EmptySecretRef {
634                name: "t".to_string()
635            })
636        );
637        assert_eq!(
638            SessionProfile::launch(
639                SessionProfileKind::Lane,
640                vec![env_grant("t", "X", Some(" "))],
641                &env
642            ),
643            Err(GrantError::EmptyExposeVar {
644                name: "t".to_string()
645            })
646        );
647    }
648}