Skip to main content

leviath_tools/
context.rs

1//! The sandbox context, tool-name aliases, and the shell-executor seam.
2
3use super::*;
4
5/// Context for tool execution - defines the sandbox root.
6pub struct ToolContext {
7    /// Absolute working directory. All file operations are confined here.
8    pub workdir: PathBuf,
9    /// The `[read_paths]` policy: which paths outside the workdir the
10    /// *read-only* file tools may fall back to, and only when both the
11    /// blueprint declares them and the user's config grants them. Inactive by
12    /// default, and never consulted by `write_file`/`edit_file` - writes are
13    /// confined to `workdir` unconditionally.
14    pub(crate) read_paths: leviath_core::ReadPathPolicy,
15    /// Per-path advisory locks serializing concurrent mutating file operations
16    /// (`write_file`/`edit_file`) on the *same* file. Fan-out sub-agent workers
17    /// share one process and one workdir, so an in-process lock map keyed by
18    /// canonical path is sufficient (no OS `flock` needed) to prevent lost
19    /// updates when two workers touch the same file. Different files never
20    /// contend.
21    file_locks: Arc<Mutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>>,
22    /// Which of the daemon's environment variables a shell command inherits.
23    /// Resolved from `[security]` at spawn; the default withholds
24    /// credential-shaped names.
25    pub(crate) shell_env: ShellEnvPolicy,
26}
27
28/// The resolved `[security] shell_env` decision for one run.
29///
30/// Carried as data rather than consulted from config at each call, so the
31/// executor has no opinion about where the decision came from and the same
32/// struct serves the shell tool, a Rhai `shell()`, and a command seed.
33#[derive(Debug, Clone, Default)]
34pub struct ShellEnvPolicy {
35    /// Which of the four filtering modes is in effect.
36    pub mode: leviath_core::ShellEnvMode,
37    /// Names handed over under every mode, from `[security] allow_env_vars`.
38    /// The same list a Rhai `env_var` read goes through, so there is one answer
39    /// to "may agent-supplied code see this variable".
40    pub allow_env_vars: Vec<String>,
41    /// Names withheld under `custom`, where the built-in name-shape heuristic
42    /// is off and only the explicit lists govern. Ignored in the other modes.
43    pub withhold: Vec<String>,
44}
45
46impl ShellEnvPolicy {
47    /// Strip the variables this policy withholds from `cmd`.
48    ///
49    /// Applied to a built `Command` rather than to an environment map, so one
50    /// call covers however the caller decided to run the thing: the host shell,
51    /// a namespace sandbox (which isolates mounts and network but still
52    /// inherits the environment), and the fallback that runs on the host when
53    /// namespaces turn out to be unusable. A container exec inherits nothing,
54    /// so this is a no-op there.
55    pub fn apply(&self, cmd: &mut tokio::process::Command) -> Vec<String> {
56        // `inherit` is the "behave as before" escape hatch, so it should cost
57        // what it did before: nothing. Without this it still walks and
58        // allocates the whole environment to decide it wants none of it.
59        if self.mode == leviath_core::ShellEnvMode::Inherit {
60            return Vec::new();
61        }
62        let names: Vec<String> = std::env::vars_os()
63            .filter_map(|(k, _)| k.into_string().ok())
64            .collect();
65        let withheld = leviath_core::withheld_child_vars(
66            names.iter().map(String::as_str),
67            self.mode,
68            &self.allow_env_vars,
69            &self.withhold,
70        );
71        for name in &withheld {
72            cmd.env_remove(name);
73        }
74        withheld
75    }
76}
77
78impl ToolContext {
79    /// Create a new context. Attempts to canonicalize the working directory.
80    pub fn new(workdir: PathBuf) -> Self {
81        let workdir = std::fs::canonicalize(&workdir).unwrap_or(workdir);
82        Self {
83            workdir,
84            read_paths: leviath_core::ReadPathPolicy::inactive(),
85            file_locks: Arc::new(Mutex::new(HashMap::new())),
86            shell_env: ShellEnvPolicy::default(),
87        }
88    }
89
90    /// Attach a `[read_paths]` policy resolved at spawn. Builder-style, like
91    /// [`BuiltinTools::with_shell_executor`].
92    pub fn with_read_paths(mut self, policy: leviath_core::ReadPathPolicy) -> Self {
93        self.read_paths = policy;
94        self
95    }
96
97    /// Attach the `[security] shell_env` decision resolved at spawn.
98    pub fn with_shell_env(mut self, policy: ShellEnvPolicy) -> Self {
99        self.shell_env = policy;
100        self
101    }
102
103    /// Get (or create) the advisory lock for `path`. The map mutex is held only
104    /// briefly to look up / insert; the returned per-file lock is what callers
105    /// `.await` on across their read-modify-write.
106    pub(crate) fn lock_for(&self, path: &Path) -> Arc<tokio::sync::Mutex<()>> {
107        let mut map = self
108            .file_locks
109            .lock()
110            .unwrap_or_else(PoisonError::into_inner);
111        map.entry(path.to_path_buf())
112            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
113            .clone()
114    }
115}
116
117/// Alias → canonical built-in tool name.
118///
119/// A blueprint's `available_tools` may name a built-in by any alias listed here;
120/// it resolves to the canonical tool that is advertised to the model and
121/// executed. This is the single source of truth for aliases - [`names`],
122/// [`BuiltinTools::execute`], and the daemon's `available_tools` filtering all go
123/// through [`canonical_tool_name`], so adding a row here is all it takes to add
124/// an alias everywhere. Add rows only for genuine synonyms of an existing tool.
125///
126/// [`names`]: BuiltinTools::names
127pub const TOOL_ALIASES: &[(&str, &str)] = &[
128    // `bash` is the familiar name for the general shell tool.
129    ("bash", "shell"),
130];
131
132/// Resolve `name` through [`TOOL_ALIASES`] to its canonical built-in name.
133///
134/// Returns the input unchanged when it is not an alias - which includes every
135/// canonical built-in and every MCP tool name, so this is safe to apply to any
136/// tool name before matching it against a definition.
137pub fn canonical_tool_name(name: &str) -> &str {
138    for (alias, canonical) in TOOL_ALIASES {
139        if *alias == name {
140            return canonical;
141        }
142    }
143    name
144}
145
146/// Every name that refers to the same tool as `name`: the name itself, its
147/// canonical form, and every alias of that canonical form.
148///
149/// For matching a tool against something a *person* wrote, rather than against
150/// a tool definition. [`canonical_tool_name`] is enough when the written name is
151/// the one being resolved, but not when it is the key of a map being searched: a
152/// call the model makes is always canonical (`shell`), so looking up only
153/// `shell` and its canonical form never finds a `bash` entry, however many
154/// spellings the writer had to choose from.
155///
156/// The first item is always `name`, so a caller that stops at the first hit
157/// prefers the exact spelling.
158pub fn tool_name_spellings(name: &str) -> impl Iterator<Item = &str> {
159    let canonical = canonical_tool_name(name);
160    std::iter::once(name)
161        .chain(std::iter::once(canonical))
162        .chain(
163            TOOL_ALIASES
164                .iter()
165                .filter(move |(_, c)| *c == canonical)
166                .map(|(alias, _)| *alias),
167        )
168        .filter({
169            let mut seen: Vec<&str> = Vec::new();
170            move |s| match seen.contains(s) {
171                true => false,
172                false => {
173                    seen.push(s);
174                    true
175                }
176            }
177        })
178}
179
180/// Redirects shell command execution off the host into a sandbox.
181///
182/// The default (no executor) runs the command directly on the host - the exact
183/// prior behavior. An implementor (the daemon's `SandboxManager`) returns a
184/// [`tokio::process::Command`] that runs `command` inside a container or Linux
185/// namespace instead. The implementor owns any per-stage sandbox state, so the
186/// same handle is used for the agent's whole life; only shell execution is
187/// affected (file tools stay on the host, over the bind-mounted workdir).
188pub trait ShellExecutor: Send + Sync {
189    /// Build the process that runs `command` via `shell flag` for `workdir`.
190    fn build_command(&self, shell: &str, flag: &str, command: &str, workdir: &Path) -> Command;
191}
192
193#[cfg(test)]
194mod shell_env_tests {
195    use super::*;
196
197    /// Asserts on the *built* command rather than a spawned one: `get_envs`
198    /// reports an explicit removal as `(name, None)`, which is deterministic on
199    /// every platform and needs no child process.
200    fn removed(policy: &ShellEnvPolicy) -> Vec<String> {
201        let mut cmd = Command::new("sh");
202        policy.apply(&mut cmd);
203        cmd.as_std()
204            .get_envs()
205            .filter(|(_, v)| v.is_none())
206            .filter_map(|(k, _)| k.to_str().map(str::to_string))
207            .collect()
208    }
209
210    /// The seam works end to end: a credential-shaped variable present in the
211    /// daemon's own environment is removed from the child, and `PATH` - which
212    /// every real command needs - is not.
213    #[test]
214    fn the_default_policy_strips_a_credential_but_not_the_path() {
215        temp_env::with_vars(
216            [
217                ("LEV_TEST_FAKE_API_KEY", Some("secret")),
218                ("LEV_TEST_ORDINARY", Some("fine")),
219            ],
220            || {
221                let out = removed(&ShellEnvPolicy::default());
222                assert!(out.iter().any(|n| n == "LEV_TEST_FAKE_API_KEY"));
223                assert!(!out.iter().any(|n| n == "LEV_TEST_ORDINARY"));
224                assert!(!out.iter().any(|n| n == "PATH"));
225            },
226        );
227    }
228
229    /// The builder carries the decision through to where the executor reads it.
230    /// Without this the policy resolves at spawn and is then dropped on the
231    /// floor, which every other assertion here would still pass.
232    #[test]
233    fn the_builder_carries_the_policy_to_the_context() {
234        let ctx = ToolContext::new(std::env::temp_dir()).with_shell_env(ShellEnvPolicy {
235            mode: leviath_core::ShellEnvMode::Custom,
236            withhold: vec!["LEV_TEST_NAMED".to_string()],
237            ..Default::default()
238        });
239        assert_eq!(ctx.shell_env.mode, leviath_core::ShellEnvMode::Custom);
240        temp_env::with_var("LEV_TEST_NAMED", Some("x"), || {
241            assert_eq!(removed(&ctx.shell_env), ["LEV_TEST_NAMED"]);
242        });
243    }
244
245    /// `inherit` is the escape hatch, and it must actually touch nothing.
246    #[test]
247    fn inherit_removes_nothing() {
248        temp_env::with_var("LEV_TEST_FAKE_API_KEY", Some("secret"), || {
249            let policy = ShellEnvPolicy {
250                mode: leviath_core::ShellEnvMode::Inherit,
251                ..Default::default()
252            };
253            assert!(removed(&policy).is_empty());
254        });
255    }
256}