Skip to main content

zeph_tools/
execution_context.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Per-turn execution environment for tool calls.
5//!
6//! An [`ExecutionContext`] is attached to a [`crate::ToolCall`] to specify the working
7//! directory and environment variable overrides for that specific call. When absent,
8//! `ShellExecutor` uses the process CWD and inherited process environment — identical to
9//! the behaviour before this module existed.
10//!
11//! # Trust model
12//!
13//! Contexts are either *untrusted* (the default, built via the public API) or *trusted*
14//! (only constructible inside `zeph-tools` / `zeph-config` via [`ExecutionContext::trusted_from_parts`]).
15//!
16//! Untrusted contexts have their env overrides re-filtered through the executor's
17//! `env_blocklist` after every merge step, so LLM-controlled callers cannot reintroduce
18//! a blocked variable.  Trusted contexts bypass that final filter — the operator who
19//! authored the TOML `[[execution.environments]]` table is the trust root.
20//!
21//! # Example
22//!
23//! ```rust
24//! use zeph_tools::ExecutionContext;
25//!
26//! let ctx = ExecutionContext::new()
27//!     .with_name("repo")
28//!     .with_cwd("/workspace/myproject")
29//!     .with_env("CARGO_TARGET_DIR", "/tmp/cargo-target");
30//!
31//! assert_eq!(ctx.name(), Some("repo"));
32//! assert!(ctx.cwd().is_some());
33//! assert_eq!(ctx.env_overrides().get("CARGO_TARGET_DIR").map(String::as_str), Some("/tmp/cargo-target"));
34//! assert!(!ctx.is_trusted());
35//! ```
36
37use std::collections::BTreeMap;
38use std::path::{Path, PathBuf};
39use std::sync::Arc;
40
41/// Per-turn execution environment for a tool call.
42///
43/// When attached to a [`crate::ToolCall`], executors that honour it (currently
44/// [`crate::ShellExecutor`]) use these values instead of the process-level CWD and
45/// skill env.  `None` on `ToolCall::context` means "use the executor default" —
46/// identical to today's behaviour.
47///
48/// Cheaply clonable (single `Arc`) so the same context can be shared across
49/// parallel tool calls in one DAG layer without copying the underlying data.
50///
51/// # Precedence
52///
53/// When the `ShellExecutor` resolves the effective `(cwd, env)` for a call, the
54/// highest-priority source wins for each dimension:
55///
56/// | Source | CWD priority | Env priority |
57/// |---|---|---|
58/// | `ToolCall.context.cwd` / `env_overrides` | 1 (highest) | 1 (highest) |
59/// | Named registry entry (looked up by `name`) | 2 | 2 |
60/// | Skill env (`set_skill_env`) | — | 3 |
61/// | `default_env` registry entry (when set) | 3 | 4 |
62/// | Process CWD | 4 | — |
63/// | Inherited process env (minus blocklist) | — | 5 (lowest) |
64///
65/// Attaching a context for telemetry tagging via `env_overrides` never silently
66/// disables `default_env` — a more specific source simply overrides a less specific one.
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct ExecutionContext {
69    inner: Arc<ExecutionContextInner>,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq)]
73struct ExecutionContextInner {
74    /// Logical name matching `[[execution.environments]]` in config. Used for audit/log
75    /// lines and to look up unspecified fields from the named registry entry.
76    name: Option<String>,
77    /// Working directory override. May be relative — `resolve_context` joins it with the
78    /// process CWD before sandbox validation.
79    cwd: Option<PathBuf>,
80    /// Extra environment variables to inject. `BTreeMap` for deterministic audit output
81    /// and stable hashing.
82    env_overrides: BTreeMap<String, String>,
83    /// `true` when built via [`ExecutionContext::trusted_from_parts`]. Trusted contexts
84    /// bypass the executor's final `env_blocklist` filter pass (step 6 of the env merge).
85    trusted: bool,
86}
87
88// ── Public untrusted builder API ──────────────────────────────────────────────
89
90impl ExecutionContext {
91    /// Construct an empty, untrusted context.
92    ///
93    /// Env overrides supplied via [`with_env`](Self::with_env) are subject to the
94    /// executor's blocklist filter before reaching the subprocess.
95    #[must_use]
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// Set the logical environment name.
101    ///
102    /// The name is matched against `[[execution.environments]]` in the agent config.
103    /// An unknown name produces a [`crate::ToolError::Execution`] at dispatch time.
104    #[must_use]
105    pub fn with_name(self, name: impl Into<String>) -> Self {
106        let mut inner = Arc::unwrap_or_clone(self.inner);
107        inner.name = Some(name.into());
108        Self {
109            inner: Arc::new(inner),
110        }
111    }
112
113    /// Set the working directory override.
114    ///
115    /// Relative paths are joined with the process CWD inside `resolve_context` before
116    /// sandbox validation. Non-existent paths are a hard error — no fallback to the
117    /// process CWD.
118    #[must_use]
119    pub fn with_cwd(self, cwd: impl Into<PathBuf>) -> Self {
120        let mut inner = Arc::unwrap_or_clone(self.inner);
121        inner.cwd = Some(cwd.into());
122        Self {
123            inner: Arc::new(inner),
124        }
125    }
126
127    /// Add a single environment variable override.
128    ///
129    /// Overwrites any prior value for the same key.  Untrusted contexts have the final
130    /// env re-filtered through the executor's `env_blocklist` — blocklisted keys are
131    /// stripped regardless of their source.
132    #[must_use]
133    pub fn with_env(self, key: impl Into<String>, value: impl Into<String>) -> Self {
134        let mut inner = Arc::unwrap_or_clone(self.inner);
135        inner.env_overrides.insert(key.into(), value.into());
136        Self {
137            inner: Arc::new(inner),
138        }
139    }
140
141    /// Add multiple environment variable overrides from an iterator.
142    ///
143    /// Equivalent to calling [`with_env`](Self::with_env) for each pair.
144    #[must_use]
145    pub fn with_envs<K, V, I>(self, iter: I) -> Self
146    where
147        K: Into<String>,
148        V: Into<String>,
149        I: IntoIterator<Item = (K, V)>,
150    {
151        let mut inner = Arc::unwrap_or_clone(self.inner);
152        for (k, v) in iter {
153            inner.env_overrides.insert(k.into(), v.into());
154        }
155        Self {
156            inner: Arc::new(inner),
157        }
158    }
159}
160
161// ── Accessors ─────────────────────────────────────────────────────────────────
162
163impl ExecutionContext {
164    /// The logical environment name, if set.
165    #[must_use]
166    pub fn name(&self) -> Option<&str> {
167        self.inner.name.as_deref()
168    }
169
170    /// The working directory override, if set.
171    #[must_use]
172    pub fn cwd(&self) -> Option<&Path> {
173        self.inner.cwd.as_deref()
174    }
175
176    /// The environment variable overrides.
177    #[must_use]
178    pub fn env_overrides(&self) -> &BTreeMap<String, String> {
179        &self.inner.env_overrides
180    }
181
182    /// Whether this context was built via the trusted constructor.
183    ///
184    /// Trusted contexts bypass the executor's final `env_blocklist` pass.
185    /// Only contexts built from operator-authored TOML (via `build_registry`) are trusted.
186    ///
187    /// # Trust downgrade
188    ///
189    /// When a call-site context wraps a trusted registry entry by name (via
190    /// [`ExecutionContext::with_name`]) but the call-site context itself is untrusted,
191    /// `resolve_context` downgrades the effective trust flag to `false`. This prevents
192    /// LLM-authored wrappers from escalating privilege by naming a trusted registry entry.
193    #[must_use]
194    pub fn is_trusted(&self) -> bool {
195        self.inner.trusted
196    }
197}
198
199// ── Trusted constructor (pub(crate) inside zeph-tools) ────────────────────────
200
201impl ExecutionContext {
202    /// Construct a trusted context from raw parts.
203    ///
204    /// Env overrides in trusted contexts bypass the executor's `env_blocklist` final pass.
205    ///
206    /// **Trust contract**: callers must ensure the values do not originate from LLM output,
207    /// plugin code, or any user-controllable source.  The only in-tree producers are:
208    /// - `ExecutionConfig::build_registry` (operator-authored TOML via `[[execution.environments]]`).
209    /// - Tests that explicitly opt in.
210    ///
211    /// Marked `pub(crate)` inside `zeph-tools`; re-exported as `pub(crate)` via
212    /// `zeph_tools::execution_context` so `zeph-config` can build registry entries without
213    /// exposing the constructor to plugins or external crates.
214    pub(crate) fn trusted_from_parts(
215        name: Option<String>,
216        cwd: Option<PathBuf>,
217        env_overrides: BTreeMap<String, String>,
218    ) -> Self {
219        Self {
220            inner: Arc::new(ExecutionContextInner {
221                name,
222                cwd,
223                env_overrides,
224                trusted: true,
225            }),
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn default_is_untrusted_and_empty() {
236        let ctx = ExecutionContext::new();
237        assert!(ctx.name().is_none());
238        assert!(ctx.cwd().is_none());
239        assert!(ctx.env_overrides().is_empty());
240        assert!(!ctx.is_trusted());
241    }
242
243    #[test]
244    fn builder_methods_chain() {
245        let ctx = ExecutionContext::new()
246            .with_name("test")
247            .with_cwd("/tmp")
248            .with_env("FOO", "bar");
249        assert_eq!(ctx.name(), Some("test"));
250        assert_eq!(ctx.cwd(), Some(Path::new("/tmp")));
251        assert_eq!(
252            ctx.env_overrides().get("FOO").map(String::as_str),
253            Some("bar")
254        );
255        assert!(!ctx.is_trusted());
256    }
257
258    #[test]
259    fn with_envs_adds_multiple() {
260        let ctx = ExecutionContext::new().with_envs([("A", "1"), ("B", "2")]);
261        assert_eq!(ctx.env_overrides().len(), 2);
262        assert_eq!(ctx.env_overrides()["A"], "1");
263        assert_eq!(ctx.env_overrides()["B"], "2");
264    }
265
266    #[test]
267    fn trusted_from_parts_is_trusted() {
268        let ctx = ExecutionContext::trusted_from_parts(
269            Some("ops".to_owned()),
270            Some(PathBuf::from("/workspace")),
271            [("SECRET_KEY".to_owned(), "val".to_owned())]
272                .into_iter()
273                .collect(),
274        );
275        assert!(ctx.is_trusted());
276        assert_eq!(ctx.name(), Some("ops"));
277    }
278
279    #[test]
280    fn clone_shares_arc() {
281        let ctx = ExecutionContext::new().with_name("shared");
282        let cloned = ctx.clone();
283        assert_eq!(ctx, cloned);
284        assert!(Arc::ptr_eq(&ctx.inner, &cloned.inner));
285    }
286
287    #[test]
288    fn with_env_overwrites_existing() {
289        let ctx = ExecutionContext::new()
290            .with_env("KEY", "first")
291            .with_env("KEY", "second");
292        assert_eq!(ctx.env_overrides()["KEY"], "second");
293    }
294}