metadata = new LinkedHashMap<>();
/**
@@ -164,6 +166,24 @@ public class AgentToolSpec {
this.approvalRequest = approvalRequest == null ? new AgentToolApprovalRequest() : approvalRequest;
}
+ /**
+ * 获取单次调用动态审批策略。
+ *
+ * @return 动态审批策略;未配置时返回 null
+ */
+ public AgentToolApprovalPolicy getApprovalPolicy() {
+ return approvalPolicy;
+ }
+
+ /**
+ * 设置单次调用动态审批策略。
+ *
+ * @param approvalPolicy 动态审批策略
+ */
+ public void setApprovalPolicy(AgentToolApprovalPolicy approvalPolicy) {
+ this.approvalPolicy = approvalPolicy;
+ }
+
/**
* 获取元数据。
*
diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolAdapter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolAdapter.java
index e7a64c1..516f231 100644
--- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolAdapter.java
+++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolAdapter.java
@@ -6,21 +6,15 @@ import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.AgentToolVisibility;
import io.agentscope.core.tool.Toolkit;
-import io.agentscope.core.tool.coding.ShellCommandTool;
-import io.agentscope.core.tool.file.ReadFileTool;
-import io.agentscope.core.tool.file.WriteFileTool;
-
-import java.nio.charset.Charset;
-import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.*;
/**
* AgentScope 内置操作工具适配器。
*
- * 该适配器只负责将 Easy-Agents 的操作工具声明转换为 AgentScope Toolkit 中的原生工具。
- * Shell 工具的人工审批不使用 AgentScope {@code ShellCommandTool} 的同步 callback,而是通过
- * Easy-Agents 现有 {@code ToolHitlInterceptor} 统一处理,以保持 SSE 暂停、恢复和审计语义一致。
+ *
该适配器将 Easy-Agents 的操作工具声明转换为与 AgentScope 1.x 工具名和 Schema 兼容的
+ * 受控实现。Shell 人工审批继续通过 Easy-Agents {@code ToolHitlInterceptor} 处理,以保持
+ * SSE 暂停、恢复和审计语义一致。
*/
public class AgentOperateToolAdapter {
@@ -28,6 +22,7 @@ public class AgentOperateToolAdapter {
public static final String LIST_DIRECTORY_TOOL = "list_directory";
public static final String WRITE_TEXT_FILE_TOOL = "write_text_file";
public static final String INSERT_TEXT_FILE_TOOL = "insert_text_file";
+ public static final String APPLY_PATCH_TOOL = "apply_patch";
public static final String EXECUTE_SHELL_COMMAND_TOOL = "execute_shell_command";
/**
@@ -75,6 +70,7 @@ public class AgentOperateToolAdapter {
names.add(WRITE_TEXT_FILE_TOOL);
names.add(INSERT_TEXT_FILE_TOOL);
}
+ case PATCH -> names.add(APPLY_PATCH_TOOL);
case SHELL -> names.add(EXECUTE_SHELL_COMMAND_TOOL);
default -> {
}
@@ -88,54 +84,66 @@ public class AgentOperateToolAdapter {
if (type == null) {
throw new AgentRuntimeException("Agent operate tool type is required.");
}
- Path baseDir = validateBaseDir(spec);
+ WorkspacePathGuard pathGuard = createPathGuard(spec);
+ WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard(
+ pathGuard, spec.getWorkspaceQuotaLimits(), spec.getWorkspaceQuotaHook());
switch (type) {
case READ_FILE -> {
assertNoToolConflict(toolkit, VIEW_TEXT_FILE_TOOL);
assertNoToolConflict(toolkit, LIST_DIRECTORY_TOOL);
- toolkit.registerTool(new ReadFileTool(baseDir.toString()));
+ SafeReadFileTool readFileTool = new SafeReadFileTool(pathGuard, quotaGuard);
+ toolkit.registerAgentTool(readFileTool.viewTextFileTool());
+ toolkit.registerAgentTool(readFileTool.listDirectoryTool());
toolSpecs.add(toolSpec(spec, VIEW_TEXT_FILE_TOOL, "View text file content.", false));
toolSpecs.add(toolSpec(spec, LIST_DIRECTORY_TOOL, "List files and directories.", false));
}
case WRITE_FILE -> {
assertNoToolConflict(toolkit, WRITE_TEXT_FILE_TOOL);
assertNoToolConflict(toolkit, INSERT_TEXT_FILE_TOOL);
- toolkit.registerTool(new WriteFileTool(baseDir.toString()));
- toolSpecs.add(toolSpec(spec, WRITE_TEXT_FILE_TOOL, "Write or replace text file content.", true));
- toolSpecs.add(toolSpec(spec, INSERT_TEXT_FILE_TOOL, "Insert text into a file.", true));
+ SafeWriteFileTool writeFileTool = new SafeWriteFileTool(pathGuard, quotaGuard);
+ toolkit.registerAgentTool(writeFileTool.writeTextFileTool());
+ toolkit.registerAgentTool(writeFileTool.insertTextFileTool());
+ toolSpecs.add(toolSpec(spec, WRITE_TEXT_FILE_TOOL, "Write or replace text file content.", false));
+ toolSpecs.add(toolSpec(spec, INSERT_TEXT_FILE_TOOL, "Insert text into a file.", false));
+ }
+ case PATCH -> {
+ assertNoToolConflict(toolkit, APPLY_PATCH_TOOL);
+ toolkit.registerAgentTool(new ApplyPatchTool(
+ pathGuard, quotaGuard, spec.getPatchMaxSize(),
+ spec.getPatchMaxFiles(), spec.getPatchMaxAffectedBytes()));
+ toolSpecs.add(toolSpec(spec, APPLY_PATCH_TOOL, "Apply a workspace text patch.", false));
}
case SHELL -> {
assertNoToolConflict(toolkit, EXECUTE_SHELL_COMMAND_TOOL);
- Charset charset = parseCharset(spec);
- toolkit.registerAgentTool(new ShellCommandTool(baseDir.toString(), spec.getShellAllowedCommands(), null,
- null, charset));
- toolSpecs.add(toolSpec(spec, EXECUTE_SHELL_COMMAND_TOOL, "Execute shell command.", true));
+ ControlledShellTool shellTool = new ControlledShellTool(pathGuard, quotaGuard, spec);
+ toolkit.registerAgentTool(shellTool);
+ AgentToolSpec shellToolSpec = toolSpec(
+ spec, EXECUTE_SHELL_COMMAND_TOOL, "Execute shell command.", true);
+ if (shellToolSpec.isApprovalRequired()) {
+ // 命令级审批策略服从 Agent 的 Shell 审批开关;关闭后仅保留安全校验。
+ shellToolSpec.setApprovalPolicy(shellTool::approvalEvaluation);
+ }
+ toolSpecs.add(shellToolSpec);
}
default -> throw new AgentRuntimeException("Unsupported agent operate tool type: " + type);
}
}
- private Path validateBaseDir(AgentOperateToolSpec spec) {
+ private WorkspacePathGuard createPathGuard(AgentOperateToolSpec spec) {
String baseDir = spec.getBaseDir();
if (baseDir == null || baseDir.isBlank()) {
throw new AgentRuntimeException("Agent operate tool baseDir is required.");
}
- Path path = Path.of(baseDir).toAbsolutePath().normalize();
if (!Path.of(baseDir).isAbsolute()) {
- throw new AgentRuntimeException("Agent operate tool baseDir must be an absolute path: " + baseDir);
- }
- return path;
- }
-
- private Charset parseCharset(AgentOperateToolSpec spec) {
- String charsetName = spec.getShellCharset();
- if (charsetName == null || charsetName.isBlank()) {
- return StandardCharsets.UTF_8;
+ throw new AgentRuntimeException("Agent operate tool baseDir must be an absolute path.");
}
try {
- return Charset.forName(charsetName.trim());
- } catch (Exception error) {
- throw new AgentRuntimeException("Invalid shell charset: " + charsetName, error);
+ return new WorkspacePathGuard(Path.of(baseDir).toAbsolutePath().normalize());
+ } catch (RuntimeException error) {
+ if (error instanceof AgentRuntimeException runtimeError) {
+ throw runtimeError;
+ }
+ throw new AgentRuntimeException("Agent operate tool baseDir is invalid.", error);
}
}
@@ -152,7 +160,7 @@ public class AgentOperateToolAdapter {
toolSpec.setVisibility(AgentToolVisibility.VISIBLE);
toolSpec.setApprovalRequired(approvalRequired);
toolSpec.setApprovalRequest(approvalRequest(operateSpec, approvalRequired));
- toolSpec.setMetadata(metadata(operateSpec));
+ toolSpec.setMetadata(metadata(operateSpec, approvalRequired));
return toolSpec;
}
@@ -168,11 +176,14 @@ public class AgentOperateToolAdapter {
return defaultRequest;
}
- private Map metadata(AgentOperateToolSpec spec) {
+ private Map metadata(AgentOperateToolSpec spec, boolean approvalRequired) {
Map metadata = new LinkedHashMap<>();
metadata.put("operateTool", true);
metadata.put("operateToolType", spec.getType().name());
- metadata.put("baseDir", spec.getBaseDir());
+ if (spec.getType() == AgentOperateToolType.SHELL && approvalRequired) {
+ metadata.put("forceApprovalCommands", List.of("rm"));
+ metadata.put("forceApprovalCommandArgument", "command");
+ }
return metadata;
}
diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolSpec.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolSpec.java
index 78df6de..e3cc53d 100644
--- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolSpec.java
+++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolSpec.java
@@ -4,13 +4,13 @@ import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest;
import java.util.LinkedHashSet;
import java.util.Set;
+import java.time.Duration;
/**
* Agent 操作类工具声明。
*
- * 操作类工具是 runtime 直接适配的 AgentScope 内置工具,用于读文件、写文件和执行 Shell。
- * 这些工具直接作用于后端 JVM 所在宿主环境,调用方必须按 agent、session 或 user 维度传入受控
- * 的绝对工作目录。
+ *
操作类工具由 runtime 适配为与 AgentScope 1.x 契约兼容的受控工具。调用方必须按
+ * agent、session 或 user 维度传入独立的绝对工作目录,并通过配额与 Shell 参数限制资源使用。
*/
public class AgentOperateToolSpec {
@@ -19,8 +19,18 @@ public class AgentOperateToolSpec {
private String baseDir;
private Boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest;
- private Set shellAllowedCommands = new LinkedHashSet<>();
- private String shellCharset;
+ private WorkspaceQuotaLimits workspaceQuotaLimits = WorkspaceQuotaLimits.unlimited();
+ private transient WorkspaceQuotaHook workspaceQuotaHook = WorkspaceQuotaHook.noop();
+ private Set shellAllowedCommands = new LinkedHashSet<>(ControlledShellTool.DEFAULT_ALLOWED_COMMANDS);
+ private String shellCharset = "UTF-8";
+ private Duration shellDefaultTimeout = Duration.ofSeconds(60);
+ private Duration shellMaxTimeout = Duration.ofSeconds(300);
+ private int shellMaxCommandLength = 4096;
+ private long shellMaxOutputSize = 1024L * 1024L;
+ private int shellMaxConcurrency = 2;
+ private long patchMaxSize = 1024L * 1024L;
+ private int patchMaxFiles = 100;
+ private long patchMaxAffectedBytes = 16L * 1024L * 1024L;
/**
* 获取操作工具类型。
@@ -76,6 +86,43 @@ public class AgentOperateToolSpec {
this.baseDir = baseDir;
}
+ /**
+ * 获取工作区配额。
+ *
+ * @return 工作区配额
+ */
+ public WorkspaceQuotaLimits getWorkspaceQuotaLimits() {
+ return workspaceQuotaLimits;
+ }
+
+ /**
+ * 设置工作区配额。
+ *
+ * @param workspaceQuotaLimits 工作区配额,null 表示不限制
+ */
+ public void setWorkspaceQuotaLimits(WorkspaceQuotaLimits workspaceQuotaLimits) {
+ this.workspaceQuotaLimits = workspaceQuotaLimits == null
+ ? WorkspaceQuotaLimits.unlimited() : workspaceQuotaLimits;
+ }
+
+ /**
+ * 获取业务侧附加配额校验 Hook。
+ *
+ * @return 配额校验 Hook
+ */
+ public WorkspaceQuotaHook getWorkspaceQuotaHook() {
+ return workspaceQuotaHook;
+ }
+
+ /**
+ * 设置业务侧附加配额校验 Hook。
+ *
+ * @param workspaceQuotaHook 配额校验 Hook,null 表示无附加校验
+ */
+ public void setWorkspaceQuotaHook(WorkspaceQuotaHook workspaceQuotaHook) {
+ this.workspaceQuotaHook = workspaceQuotaHook == null ? WorkspaceQuotaHook.noop() : workspaceQuotaHook;
+ }
+
/**
* 获取审批开关覆盖值。
*
@@ -147,4 +194,148 @@ public class AgentOperateToolSpec {
public void setShellCharset(String shellCharset) {
this.shellCharset = shellCharset;
}
+
+ /**
+ * 获取 Shell 默认超时。
+ *
+ * @return 默认超时
+ */
+ public Duration getShellDefaultTimeout() {
+ return shellDefaultTimeout;
+ }
+
+ /**
+ * 设置 Shell 默认超时。
+ *
+ * @param shellDefaultTimeout 默认超时
+ */
+ public void setShellDefaultTimeout(Duration shellDefaultTimeout) {
+ this.shellDefaultTimeout = shellDefaultTimeout;
+ }
+
+ /**
+ * 获取 Shell 最大超时。
+ *
+ * @return 最大超时
+ */
+ public Duration getShellMaxTimeout() {
+ return shellMaxTimeout;
+ }
+
+ /**
+ * 设置 Shell 最大超时。
+ *
+ * @param shellMaxTimeout 最大超时
+ */
+ public void setShellMaxTimeout(Duration shellMaxTimeout) {
+ this.shellMaxTimeout = shellMaxTimeout;
+ }
+
+ /**
+ * 获取 Shell 命令最大长度。
+ *
+ * @return 最大字符数
+ */
+ public int getShellMaxCommandLength() {
+ return shellMaxCommandLength;
+ }
+
+ /**
+ * 设置 Shell 命令最大长度。
+ *
+ * @param shellMaxCommandLength 最大字符数
+ */
+ public void setShellMaxCommandLength(int shellMaxCommandLength) {
+ this.shellMaxCommandLength = shellMaxCommandLength;
+ }
+
+ /**
+ * 获取 Shell 单次标准输出和错误输出各自的最大字节数。
+ *
+ * @return 最大字节数
+ */
+ public long getShellMaxOutputSize() {
+ return shellMaxOutputSize;
+ }
+
+ /**
+ * 设置 Shell 单次标准输出和错误输出各自的最大字节数。
+ *
+ * @param shellMaxOutputSize 最大字节数
+ */
+ public void setShellMaxOutputSize(long shellMaxOutputSize) {
+ this.shellMaxOutputSize = shellMaxOutputSize;
+ }
+
+ /**
+ * 获取 JVM 实例级 Shell 最大并发数。
+ *
+ * @return 最大并发数
+ */
+ public int getShellMaxConcurrency() {
+ return shellMaxConcurrency;
+ }
+
+ /**
+ * 设置 JVM 实例级 Shell 最大并发数。
+ *
+ * @param shellMaxConcurrency 最大并发数
+ */
+ public void setShellMaxConcurrency(int shellMaxConcurrency) {
+ this.shellMaxConcurrency = shellMaxConcurrency;
+ }
+
+ /**
+ * 获取 Patch 输入最大字节数。
+ *
+ * @return 最大字节数
+ */
+ public long getPatchMaxSize() {
+ return patchMaxSize;
+ }
+
+ /**
+ * 设置 Patch 输入最大字节数。
+ *
+ * @param patchMaxSize 最大字节数
+ */
+ public void setPatchMaxSize(long patchMaxSize) {
+ this.patchMaxSize = patchMaxSize;
+ }
+
+ /**
+ * 获取 Patch 最大影响文件数。
+ *
+ * @return 最大文件数
+ */
+ public int getPatchMaxFiles() {
+ return patchMaxFiles;
+ }
+
+ /**
+ * 设置 Patch 最大影响文件数。
+ *
+ * @param patchMaxFiles 最大文件数
+ */
+ public void setPatchMaxFiles(int patchMaxFiles) {
+ this.patchMaxFiles = patchMaxFiles;
+ }
+
+ /**
+ * 获取 Patch 影响内容最大总字节数。
+ *
+ * @return 最大字节数
+ */
+ public long getPatchMaxAffectedBytes() {
+ return patchMaxAffectedBytes;
+ }
+
+ /**
+ * 设置 Patch 影响内容最大总字节数。
+ *
+ * @param patchMaxAffectedBytes 最大字节数
+ */
+ public void setPatchMaxAffectedBytes(long patchMaxAffectedBytes) {
+ this.patchMaxAffectedBytes = patchMaxAffectedBytes;
+ }
}
diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolType.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolType.java
index e566420..b7ea95b 100644
--- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolType.java
+++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolType.java
@@ -15,6 +15,11 @@ public enum AgentOperateToolType {
*/
WRITE_FILE,
+ /**
+ * 以补丁方式新增、更新或删除工作区文本文件。
+ */
+ PATCH,
+
/**
* 在服务进程所在宿主环境执行 Shell 命令。
*/
diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ApplyPatchTool.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ApplyPatchTool.java
new file mode 100644
index 0000000..67c4667
--- /dev/null
+++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ApplyPatchTool.java
@@ -0,0 +1,300 @@
+package com.easyagents.agent.runtime.tool.operate;
+
+import com.easyagents.agent.runtime.AgentRuntimeException;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.tool.AgentTool;
+import io.agentscope.core.tool.ToolCallParam;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 有界 unified diff / context hunk 工作区补丁工具。
+ */
+final class ApplyPatchTool implements AgentTool {
+
+ private static final Logger logger = LoggerFactory.getLogger(ApplyPatchTool.class);
+
+ private final WorkspacePathGuard pathGuard;
+ private final WorkspaceQuotaGuard quotaGuard;
+ private final long maxPatchSize;
+ private final int maxFiles;
+ private final long maxAffectedBytes;
+
+ /**
+ * 创建补丁工具。
+ *
+ * @param pathGuard 路径保护器
+ * @param quotaGuard 配额保护器
+ * @param maxPatchSize Patch 输入最大字节数
+ * @param maxFiles 单次最大影响文件数
+ * @param maxAffectedBytes 原内容与新内容合计最大字节数
+ */
+ ApplyPatchTool(WorkspacePathGuard pathGuard,
+ WorkspaceQuotaGuard quotaGuard,
+ long maxPatchSize,
+ int maxFiles,
+ long maxAffectedBytes) {
+ if (maxPatchSize <= 0 || maxFiles <= 0 || maxAffectedBytes <= 0) {
+ throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID", "Patch limits must be positive.", false);
+ }
+ this.pathGuard = pathGuard;
+ this.quotaGuard = quotaGuard;
+ this.maxPatchSize = maxPatchSize;
+ this.maxFiles = maxFiles;
+ this.maxAffectedBytes = maxAffectedBytes;
+ }
+
+ /**
+ * 获取工具名。
+ *
+ * @return `apply_patch`
+ */
+ @Override
+ public String getName() {
+ return AgentOperateToolAdapter.APPLY_PATCH_TOOL;
+ }
+
+ /**
+ * 获取工具描述。
+ *
+ * @return 工具描述
+ */
+ @Override
+ public String getDescription() {
+ return "Apply a bounded unified diff to workspace-relative UTF-8 text files atomically per file.";
+ }
+
+ /**
+ * 获取参数 Schema。
+ *
+ * @return JSON Schema
+ */
+ @Override
+ public Map getParameters() {
+ return Map.of(
+ "type", "object",
+ "properties", Map.of("patch", Map.of(
+ "type", "string",
+ "description", "Unified diff or *** Begin Patch context patch")),
+ "required", List.of("patch"));
+ }
+
+ /**
+ * 解析、预检并应用补丁。
+ *
+ * @param param Tool 调用参数
+ * @return Tool 结果
+ */
+ @Override
+ public Mono callAsync(ToolCallParam param) {
+ return Mono.fromCallable(() -> apply(param)).subscribeOn(Schedulers.boundedElastic());
+ }
+
+ private ToolResultBlock apply(ToolCallParam param) {
+ try {
+ Object value = param == null ? null : param.getInput().get("patch");
+ if (!(value instanceof String patch) || patch.isBlank()) {
+ throw new WorkspaceToolException("PATCH_INVALID", "Missing required string parameter: patch.", false);
+ }
+ if (patch.getBytes(StandardCharsets.UTF_8).length > maxPatchSize) {
+ throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
+ "Patch input exceeds the configured maximum size.", false);
+ }
+ List patches = UnifiedPatchParser.parse(patch);
+ if (patches.isEmpty()) {
+ throw new WorkspaceToolException("PATCH_INVALID", "Patch does not contain file changes.", false);
+ }
+ if (patches.size() > maxFiles) {
+ throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
+ "Patch affects too many files.", false);
+ }
+ PatchPlan plan = prepare(patches);
+ commit(plan);
+ return ToolResultBlock.text("Patch applied successfully: " + plan.changes().size()
+ + " file(s), " + plan.addedLines() + " insertion(s), "
+ + plan.deletedLines() + " deletion(s).");
+ } catch (AgentRuntimeException error) {
+ return WorkspaceToolResults.error(error);
+ } catch (RuntimeException error) {
+ return WorkspaceToolResults.error(
+ new AgentRuntimeException("Unexpected patch execution failure.", error));
+ }
+ }
+
+ private PatchPlan prepare(List patches) {
+ Map originals = new LinkedHashMap<>();
+ Map desired = new LinkedHashMap<>();
+ Map resultingSizes = new LinkedHashMap<>();
+ long affectedBytes = 0;
+ int addedLines = 0;
+ int deletedLines = 0;
+ for (FilePatch patch : patches) {
+ Path target = patch.type() == PatchType.ADD
+ ? pathGuard.resolveForWrite(patch.path()) : pathGuard.resolveExistingFile(patch.path());
+ if (originals.containsKey(target)) {
+ throw new WorkspaceToolException("PATCH_INVALID", "Patch contains a duplicate target.", false);
+ }
+ byte[] original = null;
+ if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
+ quotaGuard.validateFullRead(target);
+ original = readBytes(target);
+ }
+ if (patch.type() == PatchType.ADD && original != null) {
+ throw new WorkspaceToolException("PATCH_CONFLICT", "Patch add target already exists.", false);
+ }
+ String current = original == null ? "" : WorkspaceTextFiles.decodeUtf8(original);
+ String updated = UnifiedPatchParser.apply(patch, current);
+ byte[] next = null;
+ if (patch.type() != PatchType.DELETE) {
+ next = updated.getBytes(StandardCharsets.UTF_8);
+ }
+ affectedBytes = addBounded(affectedBytes, original == null ? 0 : original.length);
+ affectedBytes = addBounded(affectedBytes, next == null ? 0 : next.length);
+ originals.put(target, original);
+ desired.put(target, next);
+ resultingSizes.put(target, next == null ? -1L : (long) next.length);
+ addedLines += patch.addedLines();
+ deletedLines += patch.deletedLines();
+ }
+ quotaGuard.validateBatch(resultingSizes);
+ return new PatchPlan(originals, desired, List.copyOf(desired.keySet()), addedLines, deletedLines);
+ }
+
+ private long addBounded(long left, long right) {
+ long value;
+ try {
+ value = Math.addExact(left, right);
+ } catch (ArithmeticException error) {
+ throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
+ "Patch affected content exceeds the configured maximum size.", false, error);
+ }
+ if (value > maxAffectedBytes) {
+ throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED",
+ "Patch affected content exceeds the configured maximum size.", false);
+ }
+ return value;
+ }
+
+ private void commit(PatchPlan plan) {
+ List committed = new ArrayList<>();
+ try {
+ for (Path target : plan.changes()) {
+ byte[] next = plan.desired().get(target);
+ pathGuard.revalidate(target);
+ if (next == null) {
+ Files.delete(target);
+ } else {
+ WorkspaceTextFiles.atomicWrite(pathGuard, target, next);
+ }
+ committed.add(target);
+ }
+ } catch (Exception commitError) {
+ Collections.reverse(committed);
+ Exception rollbackError = null;
+ for (Path target : committed) {
+ try {
+ byte[] original = plan.originals().get(target);
+ if (original == null) {
+ Files.deleteIfExists(target);
+ } else {
+ WorkspaceTextFiles.atomicWrite(pathGuard, target, original);
+ }
+ } catch (Exception error) {
+ if (rollbackError == null) {
+ rollbackError = error;
+ } else {
+ rollbackError.addSuppressed(error);
+ }
+ }
+ }
+ if (rollbackError != null) {
+ commitError.addSuppressed(rollbackError);
+ logger.error("Patch commit and rollback failed; workspace requires inspection", commitError);
+ throw new WorkspaceToolException("PATCH_ROLLBACK_FAILED",
+ "Patch commit and rollback failed; workspace requires inspection.", false, commitError);
+ }
+ logger.error("Patch commit failed and was rolled back", commitError);
+ throw new WorkspaceToolException("WORKSPACE_IO_FAILED",
+ "Patch commit failed and all changes were rolled back.", true, commitError);
+ }
+ }
+
+ private byte[] readBytes(Path target) {
+ return WorkspaceTextFiles.readUtf8(target).getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * 补丁事务计划。
+ *
+ * @param originals 提交前原内容
+ * @param desired 提交后内容,null 表示删除
+ * @param changes 有序目标列表
+ * @param addedLines 新增行数
+ * @param deletedLines 删除行数
+ */
+ private record PatchPlan(Map originals,
+ Map desired,
+ List changes,
+ int addedLines,
+ int deletedLines) {
+ }
+
+ /**
+ * 文件变更类型。
+ */
+ enum PatchType {
+ /** 新增文件。 */
+ ADD,
+ /** 更新文件。 */
+ UPDATE,
+ /** 删除文件。 */
+ DELETE
+ }
+
+ /**
+ * 单文件补丁。
+ *
+ * @param type 变更类型
+ * @param path 工作区相对路径
+ * @param hunks 上下文块
+ * @param addedLines 新增行数
+ * @param deletedLines 删除行数
+ */
+ record FilePatch(PatchType type,
+ String path,
+ List hunks,
+ int addedLines,
+ int deletedLines) {
+ }
+
+ /**
+ * 单个上下文块。
+ *
+ * @param oldStart unified diff 声明的原起始行,可空
+ * @param lines 上下文行
+ */
+ record Hunk(Integer oldStart, List lines) {
+ }
+
+ /**
+ * 上下文行。
+ *
+ * @param kind 空格表示上下文,减号表示删除,加号表示新增
+ * @param text 行内容
+ */
+ record DiffLine(char kind, String text) {
+ }
+}
diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ControlledShellTool.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ControlledShellTool.java
new file mode 100644
index 0000000..a0e3ad2
--- /dev/null
+++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ControlledShellTool.java
@@ -0,0 +1,777 @@
+package com.easyagents.agent.runtime.tool.operate;
+
+import com.easyagents.agent.runtime.AgentRuntimeException;
+import com.easyagents.agent.runtime.hitl.AgentToolApprovalEvaluation;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.tool.AgentTool;
+import io.agentscope.core.tool.ToolCallParam;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 不经过系统 Shell 解释器的受控命令执行工具。
+ *
+ * 命令先按受限引号规则拆分为参数,再直接交给 {@link ProcessBuilder}。因此管道、重定向、
+ * 命令替换和环境变量展开既会被显式拒绝,也不会被二次解释。
+ */
+public final class ControlledShellTool implements AgentTool {
+
+ /** L22 首版固定命令白名单。 */
+ public static final Set DEFAULT_ALLOWED_COMMANDS = Set.of(
+ "pwd", "ls", "cat", "head", "tail", "wc", "grep", "rg", "sed", "awk", "sort", "uniq",
+ "cut", "tr", "basename", "dirname", "stat", "file", "date", "sha256sum", "shasum", "jq",
+ "diff", "cmp", "du", "tree",
+ "mkdir", "touch", "cp", "mv", "rm", "python", "python3", "node",
+ "gzip", "gunzip", "zip", "unzip", "tar",
+ "pandoc", "soffice", "pdftoppm", "pdfinfo", "pdftotext", "pdfimages", "qpdf");
+
+ private static final Set APPROVAL_REQUIRED_COMMANDS = Set.of(
+ "mkdir", "touch", "cp", "mv", "gzip", "gunzip", "zip", "unzip", "tar",
+ "pandoc", "soffice", "pdftoppm", "pdfimages", "qpdf");
+
+ private static final Map INSTANCE_LIMITERS = new ConcurrentHashMap<>();
+ private static final Map OUTPUT_EXECUTORS = new ConcurrentHashMap<>();
+ private static final Map ACTIVE_PROCESS_TREES = new ConcurrentHashMap<>();
+ private static final AtomicInteger OUTPUT_THREAD_SEQUENCE = new AtomicInteger();
+ private static final String FORBIDDEN_METACHARACTERS = ";|&><`$";
+ private static final String TRUSTED_EXECUTABLE_PATH = "/usr/local/bin:/usr/bin:/bin";
+
+ static {
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ for (Map.Entry entry : ACTIVE_PROCESS_TREES.entrySet()) {
+ ActiveProcess active = entry.getValue();
+ active.processGroupSupport().terminate(active.processGroupId());
+ terminateProcessTreeNow(entry.getKey(), active.observedDescendants());
+ }
+ OUTPUT_EXECUTORS.values().forEach(ExecutorService::shutdownNow);
+ }, "easyagents-shell-shutdown"));
+ }
+
+ private final WorkspacePathGuard pathGuard;
+ private final WorkspaceQuotaGuard quotaGuard;
+ private final Set allowedCommands;
+ private final int defaultTimeoutSeconds;
+ private final int maxTimeoutSeconds;
+ private final int maxCommandLength;
+ private final int maxOutputSize;
+ private final Semaphore limiter;
+ private final ExecutorService outputExecutor;
+ private final ShellCommandOptionValidator optionValidator;
+ private final SafeArchiveCommandExecutor archiveCommandExecutor;
+ private final ShellProcessGroupSupport processGroupSupport;
+
+ /**
+ * 创建受控 Shell 工具。
+ *
+ * @param pathGuard 路径保护器
+ * @param quotaGuard 配额保护器
+ * @param spec 操作工具配置
+ */
+ public ControlledShellTool(WorkspacePathGuard pathGuard,
+ WorkspaceQuotaGuard quotaGuard,
+ AgentOperateToolSpec spec) {
+ this.pathGuard = pathGuard;
+ this.quotaGuard = quotaGuard;
+ this.allowedCommands = validateAllowedCommands(spec.getShellAllowedCommands());
+ this.defaultTimeoutSeconds = seconds(spec.getShellDefaultTimeout(), "shellDefaultTimeout");
+ this.maxTimeoutSeconds = seconds(spec.getShellMaxTimeout(), "shellMaxTimeout");
+ if (defaultTimeoutSeconds > maxTimeoutSeconds) {
+ throw new AgentRuntimeException("Shell default timeout must not exceed max timeout.");
+ }
+ if (spec.getShellMaxCommandLength() <= 0 || spec.getShellMaxOutputSize() <= 0
+ || spec.getShellMaxOutputSize() > Integer.MAX_VALUE || spec.getShellMaxConcurrency() <= 0
+ || spec.getShellMaxConcurrency() > 64) {
+ throw new AgentRuntimeException("Shell limits must be positive and output size must fit in memory.");
+ }
+ if (spec.getShellCharset() != null && !spec.getShellCharset().isBlank()
+ && !"UTF-8".equalsIgnoreCase(spec.getShellCharset().trim())) {
+ throw new AgentRuntimeException("Shell charset must be UTF-8.");
+ }
+ this.maxCommandLength = spec.getShellMaxCommandLength();
+ this.maxOutputSize = (int) spec.getShellMaxOutputSize();
+ this.limiter = INSTANCE_LIMITERS.computeIfAbsent(spec.getShellMaxConcurrency(), Semaphore::new);
+ this.outputExecutor = OUTPUT_EXECUTORS.computeIfAbsent(
+ spec.getShellMaxConcurrency(), ControlledShellTool::createOutputExecutor);
+ this.optionValidator = new ShellCommandOptionValidator(pathGuard);
+ this.archiveCommandExecutor = new SafeArchiveCommandExecutor(pathGuard, quotaGuard, maxOutputSize);
+ this.processGroupSupport = ShellProcessGroupSupport.detect();
+ }
+
+ /**
+ * 获取工具名。
+ *
+ * @return `execute_shell_command`
+ */
+ @Override
+ public String getName() {
+ return AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL;
+ }
+
+ /**
+ * 获取工具描述。
+ *
+ * @return 工具描述
+ */
+ @Override
+ public String getDescription() {
+ return "Execute one allowlisted command in the workspace without shell operators or host path access.";
+ }
+
+ /**
+ * 获取与 AgentScope 1.x 兼容的参数 Schema。
+ *
+ * @return JSON Schema
+ */
+ @Override
+ public Map getParameters() {
+ return Map.of(
+ "type", "object",
+ "properties", Map.of(
+ "command", Map.of("type", "string", "description", "The single command to execute"),
+ "timeout", Map.of("type", "integer", "description", "Execution timeout in seconds"),
+ "charset", Map.of("type", "string", "description", "Must be UTF-8 when supplied")),
+ "required", List.of("command"));
+ }
+
+ /**
+ * 校验并异步执行命令。
+ *
+ * @param param Tool 调用参数
+ * @return Tool 结果
+ */
+ @Override
+ public Mono callAsync(ToolCallParam param) {
+ return Mono.fromCallable(() -> execute(param)).subscribeOn(Schedulers.boundedElastic());
+ }
+
+ /**
+ * 在 HITL 事件生成前校验命令并计算单次调用的审批策略。
+ *
+ * 无效命令不弹出审批,随后由工具调用返回结构化拒绝结果。Python/Node 脚本以
+ * 脚本内容和参数的摘要作为本轮复用作用域,脚本变化后必须重新审批。
+ *
+ * @param toolInput Shell 工具入参
+ * @return 动态审批判定
+ */
+ public AgentToolApprovalEvaluation approvalEvaluation(Map toolInput) {
+ try {
+ String command = requiredCommand(toolInput);
+ List arguments = parse(command);
+ validate(arguments);
+ String executable = arguments.get(0);
+ if ("rm".equals(executable)) {
+ return AgentToolApprovalEvaluation.valid(true, true, null);
+ }
+ if (Set.of("python", "python3", "node").contains(executable)) {
+ return AgentToolApprovalEvaluation.valid(true, false, scriptApprovalScope(arguments));
+ }
+ if ("pdftotext".equals(executable)) {
+ boolean stdoutOnly = arguments.size() >= 3 && "-".equals(arguments.get(arguments.size() - 1));
+ return AgentToolApprovalEvaluation.valid(!stdoutOnly, false, null);
+ }
+ return AgentToolApprovalEvaluation.valid(
+ APPROVAL_REQUIRED_COMMANDS.contains(executable), false, null);
+ } catch (RuntimeException error) {
+ return AgentToolApprovalEvaluation.invalid();
+ }
+ }
+
+ private ToolResultBlock execute(ToolCallParam param) {
+ boolean acquired = false;
+ Process process = null;
+ long processGroupId = -1;
+ Set observedDescendants = ConcurrentHashMap.newKeySet();
+ try {
+ String command = requiredCommand(param);
+ int timeout = requestedTimeout(param);
+ validateCharset(param);
+ List arguments = parse(command);
+ validate(arguments);
+ quotaGuard.validateCurrentUsage();
+ acquired = limiter.tryAcquire(Math.min(timeout, defaultTimeoutSeconds), TimeUnit.SECONDS);
+ if (!acquired) {
+ return WorkspaceToolResults.error(
+ "SHELL_CONCURRENCY_LIMIT", "Shell execution queue is full.", true);
+ }
+ long startedAt = System.nanoTime();
+ if (SafeArchiveCommandExecutor.COMMANDS.contains(arguments.get(0))) {
+ SafeArchiveCommandExecutor.ArchiveExecutionResult archiveResult =
+ archiveCommandExecutor.execute(arguments,
+ startedAt + TimeUnit.SECONDS.toNanos(timeout));
+ quotaGuard.validateCurrentUsage();
+ long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
+ return result(0,
+ new BoundedOutput(archiveResult.output(), archiveResult.truncated()),
+ new BoundedOutput("", false), null, null, false, durationMillis);
+ }
+ ProcessBuilder processBuilder = new ProcessBuilder(processGroupSupport.wrap(arguments));
+ processBuilder.directory(pathGuard.root().toFile());
+ sanitizeEnvironment(processBuilder.environment());
+ process = processBuilder.start();
+ processGroupId = processGroupSupport.enabled() ? process.pid() : -1;
+ ACTIVE_PROCESS_TREES.put(process,
+ new ActiveProcess(observedDescendants, processGroupSupport, processGroupId));
+ CompletableFuture stdout = readBounded(process.getInputStream());
+ CompletableFuture stderr = readBounded(process.getErrorStream());
+ boolean completed;
+ try {
+ completed = waitForProcess(process, timeout, observedDescendants);
+ } catch (InterruptedException interrupted) {
+ terminateProcessTree(process, observedDescendants, processGroupId);
+ Thread.currentThread().interrupt();
+ return WorkspaceToolResults.error("SHELL_INTERRUPTED", "Shell command was interrupted.", true);
+ }
+ if (!completed) {
+ terminateProcessTree(process, observedDescendants, processGroupId);
+ } else {
+ // 白名单脚本不允许在 Tool 正常返回后遗留后台子进程。
+ processGroupSupport.terminate(processGroupId);
+ terminateObservedDescendants(observedDescendants);
+ }
+ BoundedOutput stdoutValue = awaitOutput(stdout);
+ BoundedOutput stderrValue = awaitOutput(stderr);
+ quotaGuard.validateCurrentUsage();
+ long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
+ if (!completed) {
+ return result(-1, stdoutValue, stderrValue,
+ "SHELL_TIMEOUT", "Shell command exceeded " + timeout + " seconds.", true, durationMillis);
+ }
+ return result(process.exitValue(), stdoutValue, stderrValue, null, null, false, durationMillis);
+ } catch (WorkspaceToolException error) {
+ return WorkspaceToolResults.error(error);
+ } catch (AgentRuntimeException error) {
+ return WorkspaceToolResults.error("SHELL_COMMAND_DENIED", error.getMessage(), false);
+ } catch (IOException error) {
+ return WorkspaceToolResults.error(
+ "SHELL_EXECUTION_FAILED", "Command is unavailable or could not be started.", false);
+ } catch (InterruptedException error) {
+ Thread.currentThread().interrupt();
+ return WorkspaceToolResults.error("SHELL_INTERRUPTED", "Shell execution queue wait was interrupted.", true);
+ } catch (RuntimeException error) {
+ return WorkspaceToolResults.error(
+ new AgentRuntimeException("Unexpected shell execution failure.", error));
+ } finally {
+ if (process != null) {
+ if (process.isAlive()) {
+ terminateProcessTree(process, observedDescendants, processGroupId);
+ } else {
+ processGroupSupport.terminate(processGroupId);
+ terminateObservedDescendants(observedDescendants);
+ }
+ ACTIVE_PROCESS_TREES.remove(process);
+ }
+ if (acquired) {
+ limiter.release();
+ }
+ }
+ }
+
+ private List parse(String command) {
+ List tokens = new ArrayList<>();
+ StringBuilder current = new StringBuilder();
+ char quote = 0;
+ boolean escaping = false;
+ for (int index = 0; index < command.length(); index++) {
+ char character = command.charAt(index);
+ if (character == '\n' || character == '\r' || character == '\0'
+ || Character.isISOControl(character)) {
+ throw new AgentRuntimeException("Shell control characters are not allowed.");
+ }
+ if (FORBIDDEN_METACHARACTERS.indexOf(character) >= 0 || character == '~') {
+ throw new AgentRuntimeException("Shell operators, substitutions, and expansions are not allowed.");
+ }
+ if (escaping) {
+ current.append(character);
+ escaping = false;
+ } else if (character == '\\' && quote != '\'') {
+ escaping = true;
+ } else if ((character == '\'' || character == '"')) {
+ if (quote == 0) {
+ quote = character;
+ } else if (quote == character) {
+ quote = 0;
+ } else {
+ current.append(character);
+ }
+ } else if (Character.isWhitespace(character) && quote == 0) {
+ if (!current.isEmpty()) {
+ tokens.add(current.toString());
+ current.setLength(0);
+ }
+ } else {
+ current.append(character);
+ }
+ }
+ if (escaping || quote != 0) {
+ throw new AgentRuntimeException("Shell command contains an unfinished escape or quote.");
+ }
+ if (!current.isEmpty()) {
+ tokens.add(current.toString());
+ }
+ if (tokens.isEmpty()) {
+ throw new AgentRuntimeException("Shell command is required.");
+ }
+ return tokens;
+ }
+
+ private void validate(List arguments) {
+ String executable = arguments.get(0);
+ if (executable.contains("/") || executable.contains("\\") || !allowedCommands.contains(executable)) {
+ throw new AgentRuntimeException("Shell command is not allowlisted: " + executable);
+ }
+ for (int index = 1; index < arguments.size(); index++) {
+ String argument = arguments.get(index);
+ rejectHostOrTraversalPath(argument);
+ validateExistingPathArgument(argument);
+ }
+ optionValidator.validate(arguments);
+ if ("python".equals(executable) || "python3".equals(executable)) {
+ validateScript(arguments, Set.of(".py"), "-c", "-m");
+ } else if ("node".equals(executable)) {
+ validateScript(arguments, Set.of(".js", ".mjs", ".cjs"), "-e", "--eval");
+ } else if ("rm".equals(executable)) {
+ validateRemove(arguments);
+ }
+ }
+
+ private void validateScript(List arguments, Set