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            match crate::tools::ctx_execute::refuse_unapproved(
111                batch.iter().map(|(language, _)| language.as_str()),
112            ) {
113                Ok(()) => crate::tools::ctx_execute::handle_batch(&batch),
114                Err(message) => (message, ShellOutcome::Blocked),
115            }
116        } else if action == "file" {
117            let path = require_resolved_path(ctx, args, "path")?;
118            let project_root = if ctx.project_root.is_empty() {
119                None
120            } else {
121                Some(ctx.project_root.as_str())
122            };
123            let intent = get_str(args, "intent");
124            crate::tools::ctx_execute::handle_file(&path, intent.as_deref(), project_root)
125        } else {
126            let language = get_str(args, "language")
127                .ok_or_else(|| ErrorData::invalid_params("language is required", None))?;
128            let code = get_str(args, "code")
129                .ok_or_else(|| ErrorData::invalid_params("code is required", None))?;
130            let intent = get_str(args, "intent");
131            let timeout = get_int(args, "timeout").and_then(|t| u64::try_from(t).ok());
132            match crate::tools::ctx_execute::refuse_unapproved([language.as_str()]) {
133                Ok(()) => {
134                    crate::tools::ctx_execute::handle(&language, &code, intent.as_deref(), timeout)
135                }
136                Err(message) => (message, ShellOutcome::Blocked),
137            }
138        };
139
140        let result = lean_ctx::core::redaction::redact_text_if_enabled(&result);
141        Ok(ToolOutput {
142            text: result,
143            original_tokens: 0,
144            saved_tokens: 0,
145            mode: Some(action),
146            path: None,
147            changed: false,
148            shell_outcome: Some(outcome),
149            content_blocks: None,
150        })
151    }
152
153    /// `action=file` reads exactly one source, through the admitted rooted
154    /// reader on the handler thread (`tools::ctx_execute::handle_file_in_view`)
155    /// and derives its summary from that text alone. `code` and `batch` run
156    /// arbitrary programs whose reads nothing here observes: not declared.
157    fn publishes_every_source(&self, args: Option<&Map<String, Value>>) -> bool {
158        args.and_then(|args| args.get("action"))
159            .and_then(Value::as_str)
160            == Some("file")
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::server::tool_trait::ShellOutcome;
168
169    fn args(pairs: &[(&str, &str)]) -> Map<String, Value> {
170        pairs
171            .iter()
172            .map(|(k, v)| ((*k).to_string(), Value::String((*v).to_string())))
173            .collect()
174    }
175
176    /// GHSA-rxv5-4c3x-g4q6 B: clients that may not run ctx_shell may not run
177    /// code through ctx_execute either.
178    #[test]
179    fn restricted_clients_cannot_execute_code() {
180        let code = args(&[("language", "python"), ("code", "print(1)")]);
181        let batch = args(&[
182            ("action", "batch"),
183            ("items", r#"[{"language":"python","code":"print(1)"}]"#),
184        ]);
185        let contexts = [
186            ToolContext {
187                client_role: Some("untrusted".to_string()),
188                ..ToolContext::default()
189            },
190            ToolContext {
191                client_role: Some("readonly".to_string()),
192                ..ToolContext::default()
193            },
194            ToolContext {
195                shell_access: Some(false),
196                ..ToolContext::default()
197            },
198        ];
199        for ctx in &contexts {
200            for request in [&code, &batch] {
201                let output = CtxExecuteTool
202                    .handle(request, ctx)
203                    .expect("denial is a tool result");
204                assert_eq!(output.shell_outcome, Some(ShellOutcome::Blocked));
205                assert!(
206                    output.text.contains("SHELL ACCESS DENIED"),
207                    "{}",
208                    output.text
209                );
210            }
211        }
212    }
213}