1use 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 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 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
177fn 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
184fn 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
193fn 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 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 #[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 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 #[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}