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}