Skip to main content

lean_ctx_server/tools/registered/
ctx_execute.rs

1// SPDX-License-Identifier: Apache-2.0
2use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{
7    McpTool, ShellOutcome, ToolContext, ToolOutput, get_int, get_str, require_resolved_path,
8};
9use lean_ctx::tool_defs::tool_def;
10
11pub struct CtxExecuteTool;
12
13impl McpTool for CtxExecuteTool {
14    fn name(&self) -> &'static str {
15        "ctx_execute"
16    }
17
18    fn tool_def(&self) -> Tool {
19        tool_def(
20            "ctx_execute",
21            "Run code in sandbox (11 languages) — use when conditionals, multi-line or cross-language transforms.\n\
22             ANTIPATTERN: for simple one-liners, prefer ctx_shell (lower overhead, auto-compressed).\n\
23             language=shell supports multi-line scripts but shares ctx_shell's security policy;\n\
24             other languages run only after the user approves them.\n\
25             action=code (default) for one-shot; action=batch for parallel multi-language;\n\
26             action=file to summarize admitted project text as data, without executing it.\n\
27             For code/batch, intent annotates large output; file records the intent alongside its preview. Languages: javascript,\n\
28             typescript, python, shell, ruby, go, rust, php, perl, r, elixir.",
29            json!({
30                "type": "object",
31                "properties": {
32                    "language": {
33                        "type": "string",
34                        "description": "javascript|typescript|python|shell|ruby|go|rust|php|perl|r|elixir (for action=code)"
35                    },
36                    "code": {
37                        "type": "string",
38                        "description": "Source code for action=code. Set intent to filter large output."
39                    },
40                    "intent": {
41                        "type": "string",
42                        "description": "Focus intent; triggers filtering when output is large."
43                    },
44                    "timeout": {
45                        "type": "integer",
46                        "description": "Timeout in seconds (default: 30)"
47                    },
48                    "action": {
49                        "type": "string",
50                        "description": "code (default, run script) | batch (parallel) | file (project file)"
51                    },
52                    "items": {
53                        "type": "string",
54                        "description": "JSON array of [{language, code}] for batch action."
55                    },
56                    "path": {
57                        "type": "string",
58                        "description": "Project text file for action=file; content is inspected, never executed."
59                    }
60                },
61                "oneOf": [
62                    {
63                        "properties": { "action": { "enum": ["code"] } },
64                        "required": ["language", "code"]
65                    },
66                    {
67                        "properties": { "action": { "const": "batch" } },
68                        "required": ["action", "items"]
69                    },
70                    {
71                        "properties": { "action": { "const": "file" } },
72                        "required": ["action", "path"]
73                    }
74                ]
75            }),
76        )
77    }
78
79    fn handle(
80        &self,
81        args: &Map<String, Value>,
82        ctx: &ToolContext,
83    ) -> Result<ToolOutput, ErrorData> {
84        let action = get_str(args, "action").unwrap_or_default();
85
86        // action=file inspects text and never executes it; code and batch run
87        // code, so restricted clients are refused like ctx_shell refuses them.
88        if action != "file"
89            && let Some(message) = super::ctx_shell::shell_access_denial(ctx)
90        {
91            return Ok(ToolOutput {
92                shell_outcome: Some(crate::server::tool_trait::ShellOutcome::Blocked),
93                content_blocks: None,
94                ..ToolOutput::simple(message)
95            });
96        }
97        let (result, outcome) = if action == "batch" {
98            let items_str = get_str(args, "items")
99                .ok_or_else(|| ErrorData::invalid_params("items is required for batch", None))?;
100            let items: Vec<serde_json::Value> = serde_json::from_str(&items_str)
101                .map_err(|e| ErrorData::invalid_params(format!("Invalid items JSON: {e}"), None))?;
102            let batch: Vec<(String, String)> = items
103                .iter()
104                .filter_map(|item| {
105                    let lang = item.get("language")?.as_str()?.to_string();
106                    let code = item.get("code")?.as_str()?.to_string();
107                    Some((lang, code))
108                })
109                .collect();
110            let rights = batch
111                .iter()
112                .try_for_each(|(language, code)| session_rights_check(ctx, language, code));
113            match rights.and_then(|()| {
114                crate::tools::ctx_execute::refuse_unapproved(
115                    batch.iter().map(|(language, _)| language.as_str()),
116                )
117            }) {
118                Ok(()) => {
119                    crate::tools::ctx_execute::handle_batch_in(&batch, rights_cwd(ctx).as_deref())
120                }
121                Err(message) => (message, ShellOutcome::Blocked),
122            }
123        } else if action == "file" {
124            let path = require_resolved_path(ctx, args, "path")?;
125            let project_root = if ctx.project_root.is_empty() {
126                None
127            } else {
128                Some(ctx.project_root.as_str())
129            };
130            let intent = get_str(args, "intent");
131            crate::tools::ctx_execute::handle_file(&path, intent.as_deref(), project_root)
132        } else {
133            let language = get_str(args, "language")
134                .ok_or_else(|| ErrorData::invalid_params("language is required", None))?;
135            let code = get_str(args, "code")
136                .ok_or_else(|| ErrorData::invalid_params("code is required", None))?;
137            let intent = get_str(args, "intent");
138            let timeout = get_int(args, "timeout").and_then(|t| u64::try_from(t).ok());
139            match session_rights_check(ctx, &language, &code)
140                .and_then(|()| crate::tools::ctx_execute::refuse_unapproved([language.as_str()]))
141            {
142                Ok(()) => crate::tools::ctx_execute::handle_in(
143                    &language,
144                    &code,
145                    intent.as_deref(),
146                    timeout,
147                    rights_cwd(ctx).as_deref(),
148                ),
149                Err(message) => (message, ShellOutcome::Blocked),
150            }
151        };
152
153        let result = lean_ctx::core::redaction::redact_text_if_enabled(&result);
154        Ok(ToolOutput {
155            text: result,
156            original_tokens: 0,
157            saved_tokens: 0,
158            mode: Some(action),
159            path: None,
160            changed: false,
161            shell_outcome: Some(outcome),
162            content_blocks: None,
163        })
164    }
165
166    /// `action=file` reads exactly one source, through the admitted rooted
167    /// reader on the handler thread (`tools::ctx_execute::handle_file_in_view`)
168    /// and derives its summary from that text alone. `code` and `batch` run
169    /// arbitrary programs whose reads nothing here observes: not declared.
170    fn publishes_every_source(&self, args: Option<&Map<String, Value>>) -> bool {
171        args.and_then(|args| args.get("action"))
172            .and_then(Value::as_str)
173            == Some("file")
174    }
175}
176
177/// The session's rights profile (process-v1 §9) for code this tool runs:
178/// shell code is analysed like a `ctx_shell` command line, other languages
179/// are refused under a profile.
180fn session_rights_check(ctx: &ToolContext, language: &str, code: &str) -> Result<(), String> {
181    lean_ctx::core::session_rights::current().check_code(language, code, &checked_cwd(ctx))
182}
183
184/// The directory the rights check judges relative paths against.
185fn checked_cwd(ctx: &ToolContext) -> std::path::PathBuf {
186    if ctx.project_root.is_empty() {
187        std::env::current_dir().unwrap_or_default()
188    } else {
189        std::path::PathBuf::from(&ctx.project_root)
190    }
191}
192
193/// Under a profile the code runs where it was checked (the shared daemon's
194/// own directory is not the session's); without one, as before.
195fn rights_cwd(ctx: &ToolContext) -> Option<std::path::PathBuf> {
196    lean_ctx::core::session_rights::current()
197        .is_active()
198        .then(|| checked_cwd(ctx))
199}
200
201#[cfg(test)]
202pub(crate) mod session_rights_fixture {
203    //! A Developer rights file over a temporary worktree whose
204    //! `tests/locked_test.rs` is locked (spec_commit set).
205
206    pub(crate) struct Fixture {
207        pub _dir: tempfile::TempDir,
208        pub worktree: std::path::PathBuf,
209        pub rights: std::path::PathBuf,
210    }
211
212    pub(crate) fn developer(denied_tools: &[&str]) -> Fixture {
213        let dir = tempfile::tempdir().expect("tempdir");
214        let worktree = dir.path().join("wt");
215        std::fs::create_dir_all(worktree.join("tests")).expect("tests dir");
216        std::fs::create_dir_all(worktree.join("specs/42")).expect("spec dir");
217        std::fs::write(worktree.join("tests/locked_test.rs"), "#[test] fn t() {}\n").expect("test");
218        let list = serde_json::json!({
219            "schema": "leanctx.hive.process.feature-list/v1", "process": "1.0",
220            "task": {"vault": "#acme/payments", "number": 42},
221            "spec_commit": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d",
222            "test_command": "cargo test",
223            "locked_paths": ["tests/locked_test.rs", "specs/42/features.json"],
224            "features": [{"id": "F1", "description": "d", "steps": ["s"],
225                          "tests": ["tests/locked_test.rs::t"], "passes": false}]
226        });
227        std::fs::write(worktree.join("specs/42/features.json"), list.to_string()).expect("list");
228        let record = serde_json::json!({
229            "schema": "leanctx.session-rights/v1", "process": "1.0",
230            "profile": {"id": "developer", "name": "Developer", "stages": ["build"],
231                        "delivers": "commits in its own worktree", "read": ["repo", "vault"],
232                        "write": ["own_worktree"], "deny_write": ["locked", "spec", "tests"],
233                        "may_stop": false, "availability": "now"},
234            "bindings": {"own_worktree": [worktree.to_string_lossy()]},
235            "feature_list": "specs/42/features.json",
236            "denied_tools": denied_tools,
237        });
238        let rights = dir.path().join("rights.json");
239        std::fs::write(&rights, record.to_string()).expect("rights");
240        #[cfg(unix)]
241        {
242            use std::os::unix::fs::PermissionsExt;
243            std::fs::set_permissions(&rights, std::fs::Permissions::from_mode(0o600))
244                .expect("0600");
245        }
246        Fixture {
247            _dir: dir,
248            worktree,
249            rights,
250        }
251    }
252
253    impl Fixture {
254        pub(crate) fn state(&self) -> lean_ctx::core::session_rights::State {
255            let state =
256                lean_ctx::core::session_rights::from_file_arg(Some(&self.rights.to_string_lossy()));
257            assert!(state.is_active(), "{state:?}");
258            state
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::server::tool_trait::ShellOutcome;
267
268    /// F4: ctx_execute runs code under the session rights: shell code that
269    /// writes a locked test and code in another language are refused before
270    /// anything runs.
271    #[test]
272    fn session_rights_ctx_execute_refuses_locked_writes_and_other_languages() {
273        let fx = super::session_rights_fixture::developer(&[]);
274        let ctx = ToolContext {
275            project_root: fx.worktree.to_string_lossy().into_owned(),
276            ..ToolContext::default()
277        };
278        lean_ctx::core::session_rights::sync_scope(fx.state(), || {
279            for (language, code) in [
280                ("shell", "echo x > tests/locked_test.rs"),
281                ("bash", "bash -c 'echo x > tests/locked_test.rs'"),
282                ("python", "open('tests/locked_test.rs','w').write('x')"),
283            ] {
284                let output = CtxExecuteTool
285                    .handle(&args(&[("language", language), ("code", code)]), &ctx)
286                    .expect("answer");
287                assert_eq!(
288                    output.shell_outcome,
289                    Some(ShellOutcome::Blocked),
290                    "{language}"
291                );
292                assert!(output.text.contains("rule "), "{language}: {}", output.text);
293            }
294            let batch = args(&[
295                ("action", "batch"),
296                (
297                    "items",
298                    r#"[{"language":"shell","code":"echo x >> tests/locked_test.rs"}]"#,
299                ),
300            ]);
301            let output = CtxExecuteTool.handle(&batch, &ctx).expect("answer");
302            assert_eq!(output.shell_outcome, Some(ShellOutcome::Blocked));
303            assert!(session_rights_check(&ctx, "shell", "echo ok > notes.txt").is_ok());
304            // R2-4: the code runs in the directory it was checked against,
305            // not in the server's own working directory.
306            let output = CtxExecuteTool
307                .handle(
308                    &args(&[("language", "shell"), ("code", "echo ok > made_here.txt")]),
309                    &ctx,
310                )
311                .expect("answer");
312            assert_eq!(
313                output.shell_outcome,
314                Some(ShellOutcome::Exit(0)),
315                "{}",
316                output.text
317            );
318        });
319        assert!(
320            fx.worktree.join("made_here.txt").is_file(),
321            "the checked directory is where the code ran"
322        );
323        assert_eq!(
324            std::fs::read_to_string(fx.worktree.join("tests/locked_test.rs")).expect("read"),
325            "#[test] fn t() {}\n",
326            "the locked test is unchanged"
327        );
328    }
329
330    fn args(pairs: &[(&str, &str)]) -> Map<String, Value> {
331        pairs
332            .iter()
333            .map(|(k, v)| ((*k).to_string(), Value::String((*v).to_string())))
334            .collect()
335    }
336
337    /// GHSA-rxv5-4c3x-g4q6 B: clients that may not run ctx_shell may not run
338    /// code through ctx_execute either.
339    #[test]
340    fn restricted_clients_cannot_execute_code() {
341        let code = args(&[("language", "python"), ("code", "print(1)")]);
342        let batch = args(&[
343            ("action", "batch"),
344            ("items", r#"[{"language":"python","code":"print(1)"}]"#),
345        ]);
346        let contexts = [
347            ToolContext {
348                client_role: Some("untrusted".to_string()),
349                ..ToolContext::default()
350            },
351            ToolContext {
352                client_role: Some("readonly".to_string()),
353                ..ToolContext::default()
354            },
355            ToolContext {
356                shell_access: Some(false),
357                ..ToolContext::default()
358            },
359        ];
360        for ctx in &contexts {
361            for request in [&code, &batch] {
362                let output = CtxExecuteTool
363                    .handle(request, ctx)
364                    .expect("denial is a tool result");
365                assert_eq!(output.shell_outcome, Some(ShellOutcome::Blocked));
366                assert!(
367                    output.text.contains("SHELL ACCESS DENIED"),
368                    "{}",
369                    output.text
370                );
371            }
372        }
373    }
374}