From 9612c5bd62deb90099e0bdfcd64a9715b974ce7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 19 Aug 2026 21:51:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Agent=20=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提供受控文件读写、补丁、Shell 与归档能力 - 补齐路径、配额、命令审批和进程清理边界 --- easy-agents-agent-runtime/pom.xml | 5 + .../tool/operate/AgentOperateToolAdapter.java | 74 +- .../tool/operate/AgentOperateToolSpec.java | 201 ++- .../tool/operate/AgentOperateToolType.java | 5 + .../runtime/tool/operate/ApplyPatchTool.java | 300 ++++ .../tool/operate/ControlledShellTool.java | 777 ++++++++++ .../operate/SafeArchiveCommandExecutor.java | 1319 +++++++++++++++++ .../tool/operate/SafeReadFileTool.java | 290 ++++ .../tool/operate/SafeWriteFileTool.java | 325 ++++ .../operate/ShellCommandOptionValidator.java | 601 ++++++++ .../operate/ShellProcessGroupSupport.java | 150 ++ .../tool/operate/UnifiedPatchParser.java | 287 ++++ .../tool/operate/WorkspacePathGuard.java | 311 ++++ .../tool/operate/WorkspaceQuotaGuard.java | 263 ++++ .../tool/operate/WorkspaceQuotaHook.java | 74 + .../tool/operate/WorkspaceQuotaLimits.java | 78 + .../tool/operate/WorkspaceTextFiles.java | 269 ++++ .../tool/operate/WorkspaceToolException.java | 57 + .../tool/operate/WorkspaceToolResults.java | 58 + .../AgentScopeStatefulRuntimeTest.java | 348 ++++- .../operate/AgentOperateToolAdapterTest.java | 40 +- .../tool/operate/ApplyPatchToolTest.java | 142 ++ .../tool/operate/ControlledShellToolTest.java | 347 +++++ .../SafeArchiveCommandExecutorTest.java | 228 +++ .../operate/ShellProcessGroupSupportTest.java | 35 + .../tool/operate/WorkspaceFileToolsTest.java | 188 +++ .../tool/operate/WorkspacePathGuardTest.java | 121 ++ 27 files changed, 6842 insertions(+), 51 deletions(-) create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ApplyPatchTool.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ControlledShellTool.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeArchiveCommandExecutor.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeReadFileTool.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeWriteFileTool.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ShellCommandOptionValidator.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ShellProcessGroupSupport.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/UnifiedPatchParser.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspacePathGuard.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaGuard.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaHook.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaLimits.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceTextFiles.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceToolException.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceToolResults.java create mode 100644 easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ApplyPatchToolTest.java create mode 100644 easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ControlledShellToolTest.java create mode 100644 easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/SafeArchiveCommandExecutorTest.java create mode 100644 easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ShellProcessGroupSupportTest.java create mode 100644 easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/WorkspaceFileToolsTest.java create mode 100644 easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/WorkspacePathGuardTest.java diff --git a/easy-agents-agent-runtime/pom.xml b/easy-agents-agent-runtime/pom.xml index 234d859..e2112bd 100644 --- a/easy-agents-agent-runtime/pom.xml +++ b/easy-agents-agent-runtime/pom.xml @@ -28,6 +28,11 @@ fastjson2 + + org.apache.commons + commons-compress + + com.anthropic anthropic-java 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..dc33402 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,63 @@ 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); + 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); } } @@ -172,7 +177,10 @@ public class AgentOperateToolAdapter { Map metadata = new LinkedHashMap<>(); metadata.put("operateTool", true); metadata.put("operateToolType", spec.getType().name()); - metadata.put("baseDir", spec.getBaseDir()); + if (spec.getType() == AgentOperateToolType.SHELL) { + 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 extensions, String... deniedOptions) { + if (arguments.size() < 2 || arguments.get(1).startsWith("-")) { + throw new AgentRuntimeException("Script command requires a workspace script file as its first argument."); + } + for (String denied : deniedOptions) { + if (arguments.contains(denied)) { + throw new AgentRuntimeException("Inline or module script execution is not allowed."); + } + } + String script = arguments.get(1); + if (extensions.stream().noneMatch(script::endsWith)) { + throw new AgentRuntimeException("Script file extension is not allowed."); + } + pathGuard.resolveExistingFile(script); + } + + private void validateRemove(List arguments) { + boolean hasTarget = false; + boolean recursive = false; + boolean force = false; + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + if (argument.startsWith("-")) { + String flags = argument.replace("-", ""); + recursive |= flags.contains("r") || flags.contains("R") || "recursive".equals(flags); + force |= flags.contains("f") || "force".equals(flags); + continue; + } + if (".".equals(argument) || "./".equals(argument)) { + throw new AgentRuntimeException("Workspace root cannot be removed."); + } + hasTarget = true; + } + if (!hasTarget) { + throw new AgentRuntimeException("rm requires at least one workspace target."); + } + if (recursive && force) { + throw new AgentRuntimeException("Recursive forced removal is not allowed."); + } + } + + private void rejectHostOrTraversalPath(String argument) { + if (argument.startsWith("-") + && (argument.contains("/") || argument.contains("\\") || argument.contains("~"))) { + throw new AgentRuntimeException("Shell option-embedded paths are not allowed."); + } + String candidate = optionValue(argument); + if (candidate.isEmpty() || candidate.startsWith("-")) { + return; + } + if (candidate.startsWith("/") || candidate.startsWith("\\") + || candidate.matches("^[A-Za-z]:[\\\\/].*") || candidate.startsWith("~")) { + throw new AgentRuntimeException("Shell absolute paths are not allowed."); + } + if (candidate.matches("^[A-Za-z][A-Za-z0-9+.-]*://.*") + || candidate.regionMatches(true, 0, "file:", 0, "file:".length()) + || candidate.regionMatches(true, 0, "data:", 0, "data:".length())) { + throw new AgentRuntimeException("Shell URI inputs are not allowed."); + } + for (String segment : candidate.replace('\\', '/').split("/")) { + if ("..".equals(segment)) { + throw new AgentRuntimeException("Shell path traversal is not allowed."); + } + } + } + + private void validateExistingPathArgument(String argument) { + String candidate = optionValue(argument); + if (candidate.isEmpty() || candidate.startsWith("-") || candidate.equals(".")) { + return; + } + Path possible = pathGuard.root().resolve(candidate).normalize(); + if (!possible.startsWith(pathGuard.root()) || !Files.exists(possible, LinkOption.NOFOLLOW_LINKS)) { + return; + } + pathGuard.resolveExistingEntry(candidate); + } + + private String optionValue(String argument) { + int equals = argument.indexOf('='); + return equals >= 0 ? argument.substring(equals + 1) : argument; + } + + private void sanitizeEnvironment(Map environment) { + environment.clear(); + // 固定搜索路径,避免宿主继承 PATH 中的可写目录劫持白名单命令。 + environment.put("PATH", TRUSTED_EXECUTABLE_PATH); + environment.put("PYTHONPATH", "/opt/easyflow/python-packages"); + environment.put("NODE_PATH", "/app/node_modules"); + environment.put("HOME", pathGuard.root().toString()); + environment.put("TMPDIR", pathGuard.root().toString()); + environment.put("LANG", "C.UTF-8"); + environment.put("LC_ALL", "C.UTF-8"); + } + + private String requiredCommand(ToolCallParam param) { + Object value = param == null ? null : param.getInput().get("command"); + return requiredCommand(value); + } + + /** + * 从动态审批入参中读取命令。 + * + * @param input 工具调用入参 + * @return 已完成基础校验的命令 + */ + private String requiredCommand(Map input) { + Object value = input == null ? null : input.get("command"); + return requiredCommand(value); + } + + /** + * 校验命令值与最大长度。 + * + * @param value 原始命令值 + * @return 已完成基础校验的命令 + */ + private String requiredCommand(Object value) { + if (!(value instanceof String command) || command.isBlank()) { + throw new AgentRuntimeException("Shell command is required."); + } + if (command.length() > maxCommandLength) { + throw new AgentRuntimeException("Shell command exceeds max-command-length."); + } + return command; + } + + /** + * 根据脚本内容和完整参数计算当前 Turn 的复用审批作用域。 + * + * @param arguments 命令参数 + * @return 带类型前缀的 SHA-256 审批作用域 + */ + private String scriptApprovalScope(List arguments) { + Path script = pathGuard.resolveExistingFile(arguments.get(1)); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(script)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) >= 0) { + digest.update(buffer, 0, read); + } + } + for (String argument : arguments) { + digest.update((byte) 0); + digest.update(argument.getBytes(StandardCharsets.UTF_8)); + } + return "SHELL_SCRIPT:" + java.util.HexFormat.of().formatHex(digest.digest()); + } catch (IOException error) { + throw new WorkspaceToolException( + "WORKSPACE_IO_FAILED", "Script could not be hashed before approval.", true, error); + } catch (NoSuchAlgorithmException error) { + throw new AgentRuntimeException("SHA-256 is unavailable for script approval.", error); + } + } + + private int requestedTimeout(ToolCallParam param) { + Object value = param == null ? null : param.getInput().get("timeout"); + if (value == null) { + return defaultTimeoutSeconds; + } + if (!(value instanceof Number number)) { + throw new AgentRuntimeException("Shell timeout must be an integer number of seconds."); + } + int timeout = number.intValue(); + if (timeout <= 0 || timeout > maxTimeoutSeconds) { + throw new AgentRuntimeException("Shell timeout is outside the configured range."); + } + return timeout; + } + + private void validateCharset(ToolCallParam param) { + Object value = param == null ? null : param.getInput().get("charset"); + if (value != null && (!(value instanceof String charset) || !"UTF-8".equalsIgnoreCase(charset.trim()))) { + throw new AgentRuntimeException("Shell charset override is limited to UTF-8."); + } + } + + private CompletableFuture readBounded(InputStream input) { + try { + return CompletableFuture.supplyAsync(() -> { + ByteArrayOutputStream retained = new ByteArrayOutputStream(Math.min(maxOutputSize, 8192)); + boolean truncated = false; + byte[] buffer = new byte[8192]; + try (input) { + int read; + while ((read = input.read(buffer)) >= 0) { + int remaining = maxOutputSize - retained.size(); + if (remaining > 0) { + retained.write(buffer, 0, Math.min(read, remaining)); + } + if (read > remaining) { + truncated = true; + } + } + } catch (IOException error) { + throw new WorkspaceToolException("SHELL_OUTPUT_FAILED", + "Shell output stream could not be read.", true, error); + } + return new BoundedOutput(retained.toString(StandardCharsets.UTF_8), truncated); + }, outputExecutor); + } catch (RejectedExecutionException error) { + throw new WorkspaceToolException("SHELL_CONCURRENCY_LIMIT", + "Shell output collector is at capacity.", true, error); + } + } + + private BoundedOutput awaitOutput(CompletableFuture future) { + try { + return future.get(2, TimeUnit.SECONDS); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new WorkspaceToolException("SHELL_INTERRUPTED", + "Shell output collection was interrupted.", true, error); + } catch (ExecutionException | java.util.concurrent.TimeoutException error) { + future.cancel(true); + Throwable cause = error instanceof ExecutionException && error.getCause() != null + ? error.getCause() : error; + if (cause instanceof WorkspaceToolException typed) { + throw typed; + } + throw new WorkspaceToolException("SHELL_OUTPUT_FAILED", + "Shell output could not be collected.", true, cause); + } + } + + private boolean waitForProcess(Process process, + int timeoutSeconds, + Set observedDescendants) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds); + while (process.isAlive()) { + observedDescendants.addAll(process.toHandle().descendants().toList()); + long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); + if (remainingMillis <= 0) { + return false; + } + process.waitFor(Math.max(1, Math.min(remainingMillis, 10)), TimeUnit.MILLISECONDS); + } + observedDescendants.addAll(process.toHandle().descendants().toList()); + return true; + } + + private void terminateProcessTree(Process process, + Set observedDescendants, + long processGroupId) { + processGroupSupport.terminate(processGroupId); + List descendants = new ArrayList<>(observedDescendants); + descendants.addAll(process.toHandle().descendants().toList()); + for (int index = descendants.size() - 1; index >= 0; index--) { + descendants.get(index).destroy(); + } + process.destroy(); + try { + if (!process.waitFor(500, TimeUnit.MILLISECONDS)) { + for (int index = descendants.size() - 1; index >= 0; index--) { + ProcessHandle descendant = descendants.get(index); + if (descendant.isAlive()) { + descendant.destroyForcibly(); + } + } + process.destroyForcibly(); + process.waitFor(500, TimeUnit.MILLISECONDS); + } + } catch (InterruptedException error) { + for (ProcessHandle descendant : descendants) { + if (descendant.isAlive()) { + descendant.destroyForcibly(); + } + } + process.destroyForcibly(); + Thread.currentThread().interrupt(); + } + } + + private void terminateObservedDescendants(Set observedDescendants) { + Set expanded = new LinkedHashSet<>(observedDescendants); + for (ProcessHandle descendant : observedDescendants) { + if (descendant.isAlive()) { + expanded.addAll(descendant.descendants().toList()); + } + } + for (ProcessHandle descendant : expanded) { + if (descendant.isAlive()) { + descendant.destroy(); + } + } + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(300); + while (expanded.stream().anyMatch(ProcessHandle::isAlive) + && System.nanoTime() < deadline) { + try { + Thread.sleep(10); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + break; + } + } + for (ProcessHandle descendant : expanded) { + if (descendant.isAlive()) { + descendant.destroyForcibly(); + } + } + } + + private static void terminateProcessTreeNow(Process process, Set observedDescendants) { + if (process == null) { + return; + } + List descendants = new ArrayList<>(observedDescendants); + descendants.addAll(process.toHandle().descendants().toList()); + for (int index = descendants.size() - 1; index >= 0; index--) { + ProcessHandle descendant = descendants.get(index); + if (descendant.isAlive()) { + descendant.destroyForcibly(); + } + } + if (process.isAlive()) { + process.destroyForcibly(); + } + } + + private ToolResultBlock result(int returnCode, + BoundedOutput stdout, + BoundedOutput stderr, + String errorCode, + String errorMessage, + boolean retryable, + long durationMillis) { + String error = errorCode == null ? "" : "" + errorCode + "" + + xml(errorMessage) + "" + retryable + ""; + String warning = errorCode == null && (stdout.truncated() || stderr.truncated()) + ? "OUTPUT_TRUNCATEDShell output exceeded the configured limit." + + "false" : ""; + String formatted = "" + returnCode + "" + + "" + xml(sanitizeOutput(stdout.text())) + "" + + "" + xml(sanitizeOutput(stderr.text())) + "" + + "" + durationMillis + "" + error + warning; + return ToolResultBlock.text(formatted); + } + + private String xml(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + private String sanitizeOutput(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + return value.replace(pathGuard.root().toString(), "."); + } + + private static int seconds(Duration duration, String name) { + if (duration == null || duration.isZero() || duration.isNegative() || duration.getSeconds() > Integer.MAX_VALUE) { + throw new AgentRuntimeException(name + " must be a positive whole-second duration."); + } + return Math.toIntExact(duration.getSeconds()); + } + + private static Set validateAllowedCommands(Set configured) { + if (configured == null || configured.isEmpty()) { + throw new AgentRuntimeException("Shell command whitelist must not be empty."); + } + Set normalized = new LinkedHashSet<>(); + for (String command : configured) { + if (command == null || command.isBlank() || !DEFAULT_ALLOWED_COMMANDS.contains(command.trim())) { + throw new AgentRuntimeException("Shell command is outside the fixed whitelist."); + } + normalized.add(command.trim()); + } + return Set.copyOf(normalized); + } + + private static ExecutorService createOutputExecutor(int maxConcurrency) { + int threads = Math.multiplyExact(maxConcurrency, 2); + ThreadFactory threadFactory = runnable -> { + Thread thread = new Thread(runnable, + "easyagents-shell-output-" + OUTPUT_THREAD_SEQUENCE.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + return new ThreadPoolExecutor( + threads, + threads, + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(Math.max(threads * 2, 4)), + threadFactory, + new ThreadPoolExecutor.AbortPolicy()); + } + + /** + * 有界输出。 + * + * @param text 保留文本 + * @param truncated 是否截断 + */ + private record BoundedOutput(String text, boolean truncated) { + } + + /** + * 活跃命令及其进程组清理上下文。 + * + * @param observedDescendants 执行期观察到的后代 + * @param processGroupSupport Linux 进程组支持 + * @param processGroupId Linux PGID,降级模式为 -1 + */ + private record ActiveProcess(Set observedDescendants, + ShellProcessGroupSupport processGroupSupport, + long processGroupId) { + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeArchiveCommandExecutor.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeArchiveCommandExecutor.java new file mode 100644 index 0000000..b9201bd --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeArchiveCommandExecutor.java @@ -0,0 +1,1319 @@ +package com.easyagents.agent.runtime.tool.operate; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.archivers.zip.UnixStat; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.apache.commons.compress.archivers.zip.ZipFile; +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; +import org.apache.commons.compress.compressors.gzip.GzipParameters; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.zip.CRC32; + +/** + * 使用纯 Java 流完成的工作区安全归档命令执行器。 + * + *

所有归档输入先复制到工作区外、同文件系统的临时目录。解包会先完整扫描条目,再写入 + * staging,最后以逐文件原子移动和失败补偿提交,系统归档程序不会参与执行。 + */ +final class SafeArchiveCommandExecutor { + + private static final Logger logger = LoggerFactory.getLogger(SafeArchiveCommandExecutor.class); + static final Set COMMANDS = Set.of("gzip", "gunzip", "zip", "unzip", "tar"); + + private static final Pattern WINDOWS_ABSOLUTE = Pattern.compile("^[A-Za-z]:/.*"); + private static final int MAX_PATH_LENGTH = 512; + private static final int MAX_PATH_DEPTH = 64; + private static final int COPY_BUFFER_SIZE = 16 * 1024; + + private final WorkspacePathGuard pathGuard; + private final WorkspaceQuotaGuard quotaGuard; + private final int maxOutputBytes; + private final ArchiveCommitObserver commitObserver; + + /** + * 创建归档命令执行器。 + * + * @param pathGuard 工作区路径保护器 + * @param quotaGuard 工作区配额保护器 + * @param maxOutputBytes 命令文本输出最大字节数 + */ + SafeArchiveCommandExecutor(WorkspacePathGuard pathGuard, + WorkspaceQuotaGuard quotaGuard, + int maxOutputBytes) { + this(pathGuard, quotaGuard, maxOutputBytes, ArchiveCommitObserver.noop()); + } + + /** + * 创建带提交观察器的归档执行器,供事务补偿测试使用。 + * + * @param pathGuard 工作区路径保护器 + * @param quotaGuard 工作区配额保护器 + * @param maxOutputBytes 命令文本输出最大字节数 + * @param commitObserver 提交观察器 + */ + SafeArchiveCommandExecutor(WorkspacePathGuard pathGuard, + WorkspaceQuotaGuard quotaGuard, + int maxOutputBytes, + ArchiveCommitObserver commitObserver) { + this.pathGuard = pathGuard; + this.quotaGuard = quotaGuard; + this.maxOutputBytes = maxOutputBytes; + this.commitObserver = commitObserver == null ? ArchiveCommitObserver.noop() : commitObserver; + } + + /** + * 执行已经完成 Shell 安全分词的归档命令。 + * + * @param arguments 命令及参数 + * @param deadlineNanos 单调时钟截止时间 + * @return 有界文本结果 + */ + ArchiveExecutionResult execute(List arguments, long deadlineNanos) { + if (arguments == null || arguments.isEmpty() || !COMMANDS.contains(arguments.get(0))) { + throw denied("Unsupported archive command."); + } + try (Stage stage = Stage.create(pathGuard.root())) { + return switch (arguments.get(0)) { + case "gzip" -> gzip(arguments, false, stage, deadlineNanos); + case "gunzip" -> gzip(arguments, true, stage, deadlineNanos); + case "zip" -> zip(arguments, stage, deadlineNanos); + case "unzip" -> unzip(arguments, stage, deadlineNanos); + case "tar" -> tar(arguments, stage, deadlineNanos); + default -> throw denied("Unsupported archive command."); + }; + } catch (WorkspaceToolException error) { + throw error; + } catch (IOException error) { + throw new WorkspaceToolException("ARCHIVE_IO_FAILED", + "Archive operation failed.", true, error); + } + } + + private ArchiveExecutionResult gzip(List arguments, + boolean decompress, + Stage stage, + long deadlineNanos) throws IOException { + ParsedFileList parsed = parseFileList(arguments, Set.of("-k")); + boolean keep = parsed.options().contains("-k"); + if (parsed.files().isEmpty()) { + throw denied(arguments.get(0) + " requires at least one workspace file."); + } + List outputs = new ArrayList<>(); + List deletions = new ArrayList<>(); + Map quotaPlan = new LinkedHashMap<>(); + OutputCollector output = new OutputCollector(maxOutputBytes); + long expandedTotal = 0; + for (String file : parsed.files()) { + checkDeadline(deadlineNanos); + Path input = pathGuard.resolveExistingFile(file); + String targetName = decompress ? gunzipTarget(file) : file + ".gz"; + Path target = newOutputTarget(targetName); + Snapshot snapshot = snapshotFile(input, stage, deadlineNanos); + Path stagedOutput = stage.newFile("gzip-output-"); + if (decompress) { + long written; + try (InputStream raw = Files.newInputStream(snapshot.path(), StandardOpenOption.READ); + GzipCompressorInputStream gzip = new GzipCompressorInputStream(raw); + OutputStream targetOutput = limitedFileOutput( + stagedOutput, quotaGuard.maxArchiveSingleFileSize())) { + written = transfer(gzip, targetOutput, + quotaGuard.maxArchiveSingleFileSize(), deadlineNanos, null); + } catch (IOException error) { + throw invalidArchive("GZIP input is invalid.", error); + } + expandedTotal = addExpanded(expandedTotal, written); + } else { + GzipParameters parameters = new GzipParameters(); + parameters.setModificationInstant(Instant.EPOCH); + try (OutputStream raw = limitedFileOutput( + stagedOutput, quotaGuard.maxArchiveSingleFileSize()); + GzipCompressorOutputStream gzip = new GzipCompressorOutputStream(raw, parameters); + InputStream source = Files.newInputStream(snapshot.path(), StandardOpenOption.READ)) { + transfer(source, gzip, snapshot.size(), deadlineNanos, null); + } + expandedTotal = addExpanded(expandedTotal, snapshot.size()); + } + forceFile(stagedOutput); + long outputSize = Files.size(stagedOutput); + outputs.add(new OutputPlan(target, stagedOutput)); + quotaPlan.put(target, outputSize); + if (!keep) { + deletions.add(new DeletionPlan(input, snapshot.fingerprint())); + quotaPlan.put(input, -1L); + } + output.append(pathGuard.display(target)).append('\n'); + } + quotaGuard.validateBatch(quotaPlan); + commit(stage, outputs, List.of(), deletions, deadlineNanos); + return output.result(); + } + + private ArchiveExecutionResult zip(List arguments, + Stage stage, + long deadlineNanos) throws IOException { + ParsedZip parsed = parseZip(arguments); + Path target = newOutputTarget(parsed.archive()); + List sources = snapshotSources( + parsed.inputs(), pathGuard.root(), parsed.recursive(), stage, deadlineNanos); + Path stagedArchive = stage.newFile("zip-output-"); + try (OutputStream raw = limitedFileOutput(stagedArchive, quotaGuard.maxArchiveSingleFileSize()); + ZipArchiveOutputStream zip = new ZipArchiveOutputStream(raw)) { + zip.setEncoding(StandardCharsets.UTF_8.name()); + zip.setUseLanguageEncodingFlag(true); + for (ArchiveSource source : sources) { + checkDeadline(deadlineNanos); + String name = source.directory() ? source.name() + "/" : source.name(); + ZipArchiveEntry entry = new ZipArchiveEntry(name); + entry.setTime(0L); + entry.setUnixMode(source.directory() ? UnixStat.DEFAULT_DIR_PERM : UnixStat.DEFAULT_FILE_PERM); + zip.putArchiveEntry(entry); + if (!source.directory()) { + try (InputStream input = Files.newInputStream(source.snapshot(), StandardOpenOption.READ)) { + transfer(input, zip, source.size(), deadlineNanos, null); + } + } + zip.closeArchiveEntry(); + } + zip.finish(); + } + forceFile(stagedArchive); + quotaGuard.validateWrite(target, Files.size(stagedArchive)); + commit(stage, List.of(new OutputPlan(target, stagedArchive)), List.of(), List.of(), deadlineNanos); + return textResult(pathGuard.display(target) + "\n"); + } + + private ArchiveExecutionResult unzip(List arguments, + Stage stage, + long deadlineNanos) throws IOException { + ParsedExtract parsed = parseUnzip(arguments); + Path archive = pathGuard.resolveExistingFile(parsed.archive()); + Snapshot snapshot = snapshotFile(archive, stage, deadlineNanos); + enforceZipCentralDirectoryCount(snapshot.path()); + Path destination = resolveDestination(parsed.destination()); + List plans = scanZip(snapshot.path(), destination, deadlineNanos); + return extractZip(snapshot.path(), plans, stage, deadlineNanos); + } + + private ArchiveExecutionResult tar(List arguments, + Stage stage, + long deadlineNanos) throws IOException { + ParsedTar parsed = parseTar(arguments); + if (parsed.mode().create()) { + Path target = newOutputTarget(parsed.archive()); + Path base = parsed.destination() == null + ? pathGuard.root() : pathGuard.resolveExistingDirectory(parsed.destination()); + List sources = snapshotSources( + parsed.inputs(), base, true, stage, deadlineNanos); + Path stagedArchive = stage.newFile("tar-output-"); + writeTar(stagedArchive, sources, parsed.mode().gzip(), deadlineNanos); + quotaGuard.validateWrite(target, Files.size(stagedArchive)); + commit(stage, List.of(new OutputPlan(target, stagedArchive)), List.of(), List.of(), deadlineNanos); + return textResult(pathGuard.display(target) + "\n"); + } + Path archive = pathGuard.resolveExistingFile(parsed.archive()); + Snapshot snapshot = snapshotFile(archive, stage, deadlineNanos); + Path destination = parsed.destination() == null + ? pathGuard.root() : resolveDestination(parsed.destination()); + List plans = scanTar( + snapshot.path(), destination, parsed.mode().gzip(), parsed.mode().list(), deadlineNanos); + if (parsed.mode().list()) { + OutputCollector output = new OutputCollector(maxOutputBytes); + for (EntryPlan plan : plans) { + output.append(plan.name()).append(plan.directory() ? "/\n" : "\n"); + } + return output.result(); + } + return extractTar(snapshot.path(), plans, parsed.mode().gzip(), stage, deadlineNanos); + } + + private List snapshotSources(List inputs, + Path base, + boolean recursive, + Stage stage, + long deadlineNanos) throws IOException { + if (inputs.isEmpty()) { + throw denied("Archive creation requires at least one input."); + } + EntryIndex index = new EntryIndex(); + List sources = new ArrayList<>(); + long total = 0; + for (String inputValue : inputs) { + checkDeadline(deadlineNanos); + Path input = resolveInput(base, inputValue); + String rootName = normalizeEntryName(inputValue); + if (Files.isDirectory(input, LinkOption.NOFOLLOW_LINKS)) { + if (!recursive) { + throw denied("Directory inputs require the recursive option."); + } + try (Stream paths = Files.walk(input)) { + for (Path source : (Iterable) paths::iterator) { + checkDeadline(deadlineNanos); + String relative = input.equals(source) ? "" + : input.relativize(source).toString().replace(source.getFileSystem().getSeparator(), "/"); + String name = relative.isEmpty() ? rootName : rootName + "/" + relative; + if (Files.isSymbolicLink(source)) { + throw entryDenied("Archive input contains a symbolic link."); + } + if (Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) { + index.add(name, true); + sources.add(new ArchiveSource(name, null, true, 0)); + } else if (Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) { + Path guarded = pathGuard.resolveExistingFile(pathGuard.display(source)); + Snapshot snapshot = snapshotFile(guarded, stage, deadlineNanos); + total = addExpanded(total, snapshot.size()); + index.add(name, false); + sources.add(new ArchiveSource(name, snapshot.path(), false, snapshot.size())); + } else { + throw entryDenied("Archive input contains a non-regular entry."); + } + ensureEntryCount(sources.size()); + } + } + } else { + Path guarded = pathGuard.resolveExistingFile(pathGuard.display(input)); + Snapshot snapshot = snapshotFile(guarded, stage, deadlineNanos); + total = addExpanded(total, snapshot.size()); + index.add(rootName, false); + sources.add(new ArchiveSource(rootName, snapshot.path(), false, snapshot.size())); + ensureEntryCount(sources.size()); + } + } + return sources; + } + + private Path resolveInput(Path base, String inputValue) { + String normalized = normalizeEntryName(inputValue); + String baseName = pathGuard.display(base); + String combined = ".".equals(baseName) ? normalized : baseName + "/" + normalized; + return pathGuard.resolveExistingEntry(combined); + } + + private Snapshot snapshotFile(Path source, Stage stage, long deadlineNanos) throws IOException { + pathGuard.revalidate(source); + BasicFileAttributes before = Files.readAttributes( + source, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!before.isRegularFile()) { + throw entryDenied("Archive input is not a regular file."); + } + if (before.size() > quotaGuard.maxArchiveSingleFileSize()) { + throw limitExceeded("Archive input exceeds the single-file limit."); + } + Fingerprint fingerprint = Fingerprint.from(before); + Path snapshot = stage.newFile("archive-input-"); + Set inputOptions = Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS); + try (SeekableByteChannel input = Files.newByteChannel(source, inputOptions); + InputStream inputStream = java.nio.channels.Channels.newInputStream(input); + OutputStream output = limitedFileOutput(snapshot, quotaGuard.maxArchiveSingleFileSize())) { + transfer(inputStream, output, before.size(), deadlineNanos, null); + } + forceFile(snapshot); + pathGuard.revalidate(source); + BasicFileAttributes after = Files.readAttributes( + source, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!fingerprint.matches(after) || Files.size(snapshot) != before.size()) { + throw new WorkspaceToolException("ARCHIVE_INPUT_CHANGED", + "Archive input changed while it was being copied.", true); + } + return new Snapshot(snapshot, before.size(), fingerprint); + } + + private void writeTar(Path target, + List sources, + boolean gzip, + long deadlineNanos) throws IOException { + try (OutputStream limited = limitedFileOutput(target, quotaGuard.maxArchiveSingleFileSize()); + OutputStream compressed = gzip ? newGzipOutput(limited) : limited; + TarArchiveOutputStream tar = new TarArchiveOutputStream(compressed, StandardCharsets.UTF_8.name())) { + tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + tar.setBigNumberMode(TarArchiveOutputStream.BIGNUMBER_ERROR); + for (ArchiveSource source : sources) { + checkDeadline(deadlineNanos); + TarArchiveEntry entry = new TarArchiveEntry( + source.directory() ? source.name() + "/" : source.name()); + entry.setMode(source.directory() ? TarArchiveEntry.DEFAULT_DIR_MODE : TarArchiveEntry.DEFAULT_FILE_MODE); + entry.setModTime(0L); + entry.setSize(source.directory() ? 0 : source.size()); + tar.putArchiveEntry(entry); + if (!source.directory()) { + try (InputStream input = Files.newInputStream(source.snapshot(), StandardOpenOption.READ)) { + transfer(input, tar, source.size(), deadlineNanos, null); + } + } + tar.closeArchiveEntry(); + } + tar.finish(); + } + forceFile(target); + } + + private List scanZip(Path archive, + Path destination, + long deadlineNanos) { + List plans = new ArrayList<>(); + EntryIndex index = new EntryIndex(); + long total = 0; + try (ZipFile zip = new ZipFile(archive)) { + Enumeration entries = zip.getEntriesInPhysicalOrder(); + while (entries.hasMoreElements()) { + checkDeadline(deadlineNanos); + ZipArchiveEntry entry = entries.nextElement(); + String name = strictZipEntryName(entry); + boolean directory = entry.isDirectory(); + validateZipType(zip, entry); + index.add(name, directory); + ensureEntryCount(totalEntryCounter(plans)); + long size = directory ? 0 : entry.getSize(); + long compressed = directory ? 0 : entry.getCompressedSize(); + if (size < 0 || compressed < 0 || (!directory && entry.getCrc() < 0)) { + throw invalidArchive("ZIP entry metadata is incomplete.", null); + } + if (!directory) { + ensureSingleSize(size); + total = addExpanded(total, size); + } + plans.add(entryPlan(destination, name, directory, size, entry.getCrc())); + } + } catch (WorkspaceToolException error) { + throw error; + } catch (IOException error) { + throw invalidArchive("ZIP archive is invalid.", error); + } + return plans; + } + + private ArchiveExecutionResult extractZip(Path archive, + List plans, + Stage stage, + long deadlineNanos) throws IOException { + List outputs = new ArrayList<>(); + List directories = new ArrayList<>(); + Map quotaPlan = quotaExtractionPlan(plans); + OutputCollector result = new OutputCollector(maxOutputBytes); + try (ZipFile zip = new ZipFile(archive)) { + for (EntryPlan plan : plans) { + checkDeadline(deadlineNanos); + if (plan.directory()) { + directories.add(plan.target()); + result.append(plan.name()).append("/\n"); + continue; + } + ZipArchiveEntry entry = zip.getEntry(plan.name()); + if (entry == null) { + throw invalidArchive("ZIP entry disappeared between validation and extraction.", null); + } + Path staged = stage.newFile("zip-entry-"); + CRC32 crc = new CRC32(); + try (InputStream input = zip.getInputStream(entry); + OutputStream output = limitedFileOutput(staged, quotaGuard.maxArchiveSingleFileSize())) { + long written = transfer(input, output, plan.size(), deadlineNanos, crc); + if (written != plan.size() || crc.getValue() != plan.crc()) { + throw invalidArchive("ZIP entry size or CRC does not match metadata.", null); + } + } + forceFile(staged); + outputs.add(new OutputPlan(plan.target(), staged)); + result.append(plan.name()).append('\n'); + } + } catch (WorkspaceToolException error) { + throw error; + } catch (IOException error) { + throw invalidArchive("ZIP archive data is invalid.", error); + } + quotaGuard.validateBatch(quotaPlan); + commit(stage, outputs, directories, List.of(), deadlineNanos); + return result.result(); + } + + private List scanTar(Path archive, + Path destination, + boolean gzip, + boolean listOnly, + long deadlineNanos) { + List plans = new ArrayList<>(); + EntryIndex index = new EntryIndex(); + long total = 0; + try (TarArchiveInputStream tar = openTar(archive, gzip)) { + TarArchiveEntry entry; + while ((entry = tar.getNextTarEntry()) != null) { + checkDeadline(deadlineNanos); + validateTarType(entry); + String name = normalizeEntryName(entry.getName()); + boolean directory = entry.isDirectory(); + index.add(name, directory); + ensureEntryCount(plans.size() + 1); + long size = directory ? 0 : entry.getSize(); + if (!directory) { + ensureSingleSize(size); + total = addExpanded(total, size); + } + plans.add(listOnly + ? new EntryPlan(name, null, directory, size, -1) + : entryPlan(destination, name, directory, size, -1)); + } + } catch (WorkspaceToolException error) { + throw error; + } catch (IOException error) { + throw invalidArchive("TAR archive is invalid.", error); + } + return plans; + } + + private ArchiveExecutionResult extractTar(Path archive, + List plans, + boolean gzip, + Stage stage, + long deadlineNanos) throws IOException { + List outputs = new ArrayList<>(); + List directories = new ArrayList<>(); + Map quotaPlan = quotaExtractionPlan(plans); + OutputCollector result = new OutputCollector(maxOutputBytes); + int index = 0; + try (TarArchiveInputStream tar = openTar(archive, gzip)) { + TarArchiveEntry entry; + while ((entry = tar.getNextTarEntry()) != null) { + checkDeadline(deadlineNanos); + EntryPlan plan = plans.get(index++); + if (plan.directory()) { + directories.add(plan.target()); + result.append(plan.name()).append("/\n"); + continue; + } + Path staged = stage.newFile("tar-entry-"); + try (OutputStream output = limitedFileOutput(staged, quotaGuard.maxArchiveSingleFileSize())) { + long written = transfer(tar, output, plan.size(), deadlineNanos, null); + if (written != plan.size()) { + throw invalidArchive("TAR entry size does not match its header.", null); + } + } + forceFile(staged); + outputs.add(new OutputPlan(plan.target(), staged)); + result.append(plan.name()).append('\n'); + } + } catch (WorkspaceToolException error) { + throw error; + } catch (IOException | IndexOutOfBoundsException error) { + throw invalidArchive("TAR archive data is invalid.", error); + } + quotaGuard.validateBatch(quotaPlan); + commit(stage, outputs, directories, List.of(), deadlineNanos); + return result.result(); + } + + private Map quotaExtractionPlan(List plans) { + Map quotaPlan = new LinkedHashMap<>(); + for (EntryPlan plan : plans) { + if (plan.directory()) { + if (!Files.exists(plan.target(), LinkOption.NOFOLLOW_LINKS)) { + quotaPlan.put(plan.target(), 0L); + } + } else { + quotaPlan.put(plan.target(), plan.size()); + } + } + return quotaPlan; + } + + private EntryPlan entryPlan(Path destination, + String name, + boolean directory, + long size, + long crc) { + String destinationName = pathGuard.display(destination); + String combined = ".".equals(destinationName) ? name : destinationName + "/" + name; + Path target = directory ? pathGuard.resolveCommandPath(combined) : pathGuard.resolveForWrite(combined); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + if (directory && Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) { + return new EntryPlan(name, target, true, 0, crc); + } + throw new WorkspaceToolException("ARCHIVE_TARGET_CONFLICT", + "Archive output target already exists.", false); + } + return new EntryPlan(name, target, directory, size, crc); + } + + private void commit(Stage stage, + List outputs, + List directories, + List deletions, + long deadlineNanos) { + List createdDirectories = new ArrayList<>(); + List committedOutputs = new ArrayList<>(); + Map deletionBackups = new LinkedHashMap<>(); + try { + checkDeadline(deadlineNanos); + for (OutputPlan output : outputs) { + pathGuard.revalidate(output.target()); + if (Files.exists(output.target(), LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("ARCHIVE_TARGET_CONFLICT", + "Archive output target already exists.", false); + } + } + for (DeletionPlan deletion : deletions) { + verifyFingerprint(deletion.target(), deletion.fingerprint()); + } + for (Path directory : directories.stream().sorted(Comparator.comparingInt(Path::getNameCount)).toList()) { + ensureDirectory(directory, createdDirectories); + } + for (OutputPlan output : outputs) { + ensureDirectory(output.target().getParent(), createdDirectories); + } + for (DeletionPlan deletion : deletions) { + Path backup = stage.newFile("archive-backup-"); + Files.deleteIfExists(backup); + atomicMove(deletion.target(), backup); + deletionBackups.put(deletion.target(), backup); + } + for (OutputPlan output : outputs) { + checkDeadline(deadlineNanos); + atomicMove(output.staged(), output.target()); + committedOutputs.add(output.target()); + commitObserver.afterOutputCommitted(committedOutputs.size()); + forceDirectory(output.target().getParent()); + } + } catch (Exception error) { + Exception rollbackError = rollback(committedOutputs, deletionBackups, createdDirectories); + if (rollbackError != null) { + error.addSuppressed(rollbackError); + throw new WorkspaceToolException("ARCHIVE_ROLLBACK_FAILED", + "Archive commit and rollback failed; workspace requires inspection.", false, error); + } + if (error instanceof WorkspaceToolException typed) { + throw typed; + } + throw new WorkspaceToolException("ARCHIVE_COMMIT_FAILED", + "Archive commit failed and changes were rolled back.", true, error); + } + } + + private Exception rollback(List committedOutputs, + Map deletionBackups, + List createdDirectories) { + Exception failure = null; + for (int index = committedOutputs.size() - 1; index >= 0; index--) { + try { + Files.deleteIfExists(committedOutputs.get(index)); + } catch (IOException error) { + failure = appendFailure(failure, error); + } + } + List> backups = new ArrayList<>(deletionBackups.entrySet()); + for (int index = backups.size() - 1; index >= 0; index--) { + try { + atomicMove(backups.get(index).getValue(), backups.get(index).getKey()); + } catch (IOException error) { + failure = appendFailure(failure, error); + } + } + for (int index = createdDirectories.size() - 1; index >= 0; index--) { + try { + Files.deleteIfExists(createdDirectories.get(index)); + } catch (IOException error) { + failure = appendFailure(failure, error); + } + } + return failure; + } + + private Exception appendFailure(Exception current, Exception next) { + if (current == null) { + return next; + } + current.addSuppressed(next); + return current; + } + + private void ensureDirectory(Path directory, List created) throws IOException { + if (directory == null || directory.equals(pathGuard.root())) { + return; + } + Path current = pathGuard.root(); + for (Path segment : pathGuard.root().relativize(directory)) { + current = current.resolve(segment); + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(current)) { + throw new WorkspaceToolException("ARCHIVE_TARGET_CONFLICT", + "Archive output parent conflicts with an existing entry.", false); + } + } else { + Files.createDirectory(current); + created.add(current); + } + } + } + + private void verifyFingerprint(Path target, Fingerprint expected) throws IOException { + pathGuard.revalidate(target); + BasicFileAttributes actual = Files.readAttributes( + target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!expected.matches(actual)) { + throw new WorkspaceToolException("ARCHIVE_INPUT_CHANGED", + "Archive input changed before commit.", true); + } + } + + private TarArchiveInputStream openTar(Path archive, boolean gzip) throws IOException { + InputStream raw = Files.newInputStream(archive, StandardOpenOption.READ); + try { + InputStream input = gzip ? new GzipCompressorInputStream(raw) : raw; + return new TarArchiveInputStream(input, StandardCharsets.UTF_8.name()); + } catch (IOException error) { + raw.close(); + throw error; + } + } + + private OutputStream newGzipOutput(OutputStream output) throws IOException { + GzipParameters parameters = new GzipParameters(); + parameters.setModificationInstant(Instant.EPOCH); + return new GzipCompressorOutputStream(output, parameters); + } + + private void validateZipType(ZipFile zip, ZipArchiveEntry entry) { + if (!zip.canReadEntryData(entry)) { + throw entryDenied("Encrypted or unsupported ZIP entries are not allowed."); + } + int mode = entry.getUnixMode(); + int type = mode & UnixStat.FILE_TYPE_FLAG; + if (entry.isUnixSymlink() || (type != 0 && type != UnixStat.FILE_FLAG && type != UnixStat.DIR_FLAG)) { + throw entryDenied("ZIP links, devices, and other special entries are not allowed."); + } + } + + private void validateTarType(TarArchiveEntry entry) { + if (!entry.isCheckSumOK()) { + throw invalidArchive("TAR entry checksum is invalid.", null); + } + if (entry.isSymbolicLink() || entry.isLink() || entry.isBlockDevice() || entry.isCharacterDevice() + || entry.isFIFO() || entry.isSparse() || (!entry.isFile() && !entry.isDirectory())) { + throw entryDenied("TAR links, devices, FIFO, sparse, and special entries are not allowed."); + } + } + + private String strictZipEntryName(ZipArchiveEntry entry) { + byte[] raw = entry.getRawName(); + if (raw == null) { + throw invalidArchive("ZIP entry name bytes are missing.", null); + } + try { + String decoded = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(raw)).toString(); + if (!decoded.equals(entry.getName())) { + throw invalidArchive("ZIP entry name is not unambiguous UTF-8.", null); + } + return normalizeEntryName(decoded); + } catch (CharacterCodingException error) { + throw invalidArchive("ZIP entry name is not valid UTF-8.", error); + } + } + + private String normalizeEntryName(String rawName) { + if (rawName == null || rawName.isBlank() || rawName.indexOf('\0') >= 0) { + throw entryDenied("Archive entry path is empty or contains NUL."); + } + if (rawName.indexOf('\\') >= 0) { + throw entryDenied("Archive entry path must use forward slashes."); + } + String value = rawName; + while (value.startsWith("./")) { + value = value.substring(2); + } + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + if (value.isBlank() || value.startsWith("/") || value.startsWith("~") + || WINDOWS_ABSOLUTE.matcher(value).matches()) { + throw entryDenied("Archive entry path must be workspace-relative."); + } + String[] segments = value.split("/", -1); + if (segments.length > MAX_PATH_DEPTH || value.length() > MAX_PATH_LENGTH) { + throw entryDenied("Archive entry path exceeds the safety limit."); + } + for (String segment : segments) { + if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { + throw entryDenied("Archive entry path contains an unsafe segment."); + } + } + return String.join("/", segments); + } + + private void enforceZipCentralDirectoryCount(Path archive) throws IOException { + long size = Files.size(archive); + int tailSize = (int) Math.min(size, 65_557L); + byte[] tail = new byte[tailSize]; + try (FileChannel channel = FileChannel.open(archive, StandardOpenOption.READ)) { + channel.position(size - tailSize); + ByteBuffer buffer = ByteBuffer.wrap(tail); + while (buffer.hasRemaining() && channel.read(buffer) >= 0) { + // 读取 ZIP 末尾固定有界窗口。 + } + } + for (int index = tail.length - 22; index >= 0; index--) { + if (littleEndianInt(tail, index) != 0x06054b50) { + continue; + } + int commentLength = littleEndianShort(tail, index + 20); + if (index + 22 + commentLength != tail.length) { + continue; + } + int entries = littleEndianShort(tail, index + 10); + if (entries == 0xffff || entries > quotaGuard.maxArchiveEntries()) { + throw limitExceeded("ZIP contains too many entries."); + } + return; + } + throw invalidArchive("ZIP end-of-central-directory record is missing.", null); + } + + private int littleEndianInt(byte[] value, int offset) { + if (offset < 0 || offset + 4 > value.length) { + return -1; + } + return (value[offset] & 0xff) | ((value[offset + 1] & 0xff) << 8) + | ((value[offset + 2] & 0xff) << 16) | ((value[offset + 3] & 0xff) << 24); + } + + private int littleEndianShort(byte[] value, int offset) { + return (value[offset] & 0xff) | ((value[offset + 1] & 0xff) << 8); + } + + private ParsedFileList parseFileList(List arguments, Set allowedOptions) { + Set options = new LinkedHashSet<>(); + List files = new ArrayList<>(); + boolean endOfOptions = false; + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + if (!endOfOptions && "--".equals(argument)) { + endOfOptions = true; + } else if (!endOfOptions && argument.startsWith("-")) { + if (!allowedOptions.contains(argument) || !options.add(argument)) { + throw denied("Archive command option is not allowed."); + } + } else { + files.add(argument); + } + } + return new ParsedFileList(options, files); + } + + private ParsedZip parseZip(List arguments) { + boolean quiet = false; + boolean recursive = false; + List operands = new ArrayList<>(); + boolean endOfOptions = false; + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + if (!endOfOptions && "--".equals(argument)) { + endOfOptions = true; + } else if (!endOfOptions && argument.startsWith("-")) { + for (int flag = 1; flag < argument.length(); flag++) { + if (argument.charAt(flag) == 'q') { + quiet = true; + } else if (argument.charAt(flag) == 'r') { + recursive = true; + } else { + throw denied("zip option is not allowed."); + } + } + } else { + operands.add(argument); + } + } + if (operands.size() < 2) { + throw denied("zip requires an archive path and at least one input."); + } + return new ParsedZip(quiet, recursive, operands.get(0), operands.subList(1, operands.size())); + } + + private ParsedExtract parseUnzip(List arguments) { + boolean quiet = false; + String archive = null; + String destination = "."; + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + if ("-q".equals(argument) && archive == null && !quiet) { + quiet = true; + } else if (archive == null && !argument.startsWith("-")) { + archive = argument; + } else if ("-d".equals(argument) && index + 1 < arguments.size() && ".".equals(destination)) { + destination = arguments.get(++index); + } else { + throw denied("unzip option or operand is not allowed."); + } + } + if (archive == null) { + throw denied("unzip requires one archive path."); + } + return new ParsedExtract(quiet, archive, destination); + } + + private ParsedTar parseTar(List arguments) { + if (arguments.size() < 3) { + throw denied("tar requires a fixed mode and archive path."); + } + TarMode mode = TarMode.parse(arguments.get(1)); + String archive = arguments.get(2); + String destination = null; + List inputs = new ArrayList<>(); + for (int index = 3; index < arguments.size(); index++) { + String argument = arguments.get(index); + if ("-C".equals(argument) && destination == null && index + 1 < arguments.size()) { + destination = arguments.get(++index); + } else if (argument.startsWith("-")) { + throw denied("tar option is not allowed."); + } else { + inputs.add(argument); + } + } + if (mode.create() && inputs.isEmpty()) { + throw denied("tar creation requires at least one input."); + } + if (!mode.create() && !inputs.isEmpty()) { + throw denied("tar extraction and listing do not accept member filters."); + } + if (mode.list() && destination != null) { + throw denied("tar listing does not accept -C."); + } + return new ParsedTar(mode, archive, destination, inputs); + } + + private Path newOutputTarget(String value) { + Path target = pathGuard.resolveForWrite(value); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("ARCHIVE_TARGET_CONFLICT", + "Archive output target already exists.", false); + } + return target; + } + + private Path resolveDestination(String value) { + Path destination = pathGuard.resolveCommandPath(value == null ? "." : value); + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS) + && !Files.isDirectory(destination, LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("ARCHIVE_TARGET_CONFLICT", + "Archive destination is not a directory.", false); + } + return destination; + } + + private String gunzipTarget(String value) { + if (value == null || !value.toLowerCase(Locale.ROOT).endsWith(".gz") || value.length() <= 3) { + throw denied("gunzip input must end with .gz."); + } + return value.substring(0, value.length() - 3); + } + + private long transfer(InputStream input, + OutputStream output, + long limit, + long deadlineNanos, + CRC32 crc) throws IOException { + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + long total = 0; + int read; + while ((read = input.read(buffer)) >= 0) { + checkDeadline(deadlineNanos); + if (read == 0) { + continue; + } + if (total > limit - read) { + throw limitExceeded("Archive content exceeds the configured size limit."); + } + output.write(buffer, 0, read); + if (crc != null) { + crc.update(buffer, 0, read); + } + total += read; + } + return total; + } + + private OutputStream limitedFileOutput(Path path, long limit) throws IOException { + return new LimitedOutputStream(Files.newOutputStream( + path, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING), limit); + } + + private void ensureEntryCount(int count) { + if (count > quotaGuard.maxArchiveEntries()) { + throw limitExceeded("Archive contains too many entries."); + } + } + + private int totalEntryCounter(List plans) { + return plans.size() + 1; + } + + private void ensureSingleSize(long size) { + if (size < 0 || size > quotaGuard.maxArchiveSingleFileSize()) { + throw limitExceeded("Archive entry exceeds the single-file limit."); + } + } + + private long addExpanded(long current, long size) { + ensureSingleSize(size); + long total; + try { + total = Math.addExact(current, size); + } catch (ArithmeticException error) { + throw limitExceeded("Archive expanded size overflowed."); + } + if (total > quotaGuard.maxArchiveTotalSize()) { + throw limitExceeded("Archive exceeds the expanded total-size limit."); + } + return total; + } + + private void checkDeadline(long deadlineNanos) { + if (Thread.currentThread().isInterrupted() || System.nanoTime() > deadlineNanos) { + throw new WorkspaceToolException("SHELL_TIMEOUT", + "Archive command exceeded its timeout.", true); + } + } + + private void forceFile(Path path) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.WRITE)) { + channel.force(true); + } + } + + private void forceDirectory(Path directory) { + if (directory == null) { + return; + } + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (IOException | UnsupportedOperationException ignored) { + // 某些文件系统不支持目录 fsync;文件已完成 force 和原子 rename。 + } + } + + private void atomicMove(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException error) { + throw new WorkspaceToolException("ARCHIVE_ATOMIC_MOVE_UNSUPPORTED", + "Workspace filesystem does not support atomic archive commits.", false, error); + } + } + + private ArchiveExecutionResult textResult(String value) { + OutputCollector output = new OutputCollector(maxOutputBytes); + output.append(value); + return output.result(); + } + + private WorkspaceToolException denied(String message) { + return new WorkspaceToolException("SHELL_COMMAND_DENIED", message, false); + } + + private WorkspaceToolException entryDenied(String message) { + return new WorkspaceToolException("ARCHIVE_ENTRY_DENIED", message, false); + } + + private WorkspaceToolException limitExceeded(String message) { + return new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", message, false); + } + + private WorkspaceToolException invalidArchive(String message, Throwable cause) { + return cause == null + ? new WorkspaceToolException("ARCHIVE_INVALID", message, false) + : new WorkspaceToolException("ARCHIVE_INVALID", message, false, cause); + } + + /** + * 有界归档命令结果。 + * + * @param output 文本输出 + * @param truncated 是否截断 + */ + record ArchiveExecutionResult(String output, boolean truncated) { + } + + /** + * 归档提交观察器。 + */ + @FunctionalInterface + interface ArchiveCommitObserver { + + /** + * 在一个输出完成原子移动后回调。 + * + * @param committedCount 已提交输出数 + */ + void afterOutputCommitted(int committedCount); + + /** + * 获取无操作观察器。 + * + * @return 无操作观察器 + */ + static ArchiveCommitObserver noop() { + return committedCount -> { + }; + } + } + + private record ParsedFileList(Set options, List files) { + } + + private record ParsedZip(boolean quiet, boolean recursive, String archive, List inputs) { + } + + private record ParsedExtract(boolean quiet, String archive, String destination) { + } + + private record ParsedTar(TarMode mode, String archive, String destination, List inputs) { + } + + private record ArchiveSource(String name, Path snapshot, boolean directory, long size) { + } + + private record EntryPlan(String name, Path target, boolean directory, long size, long crc) { + } + + private record OutputPlan(Path target, Path staged) { + } + + private record DeletionPlan(Path target, Fingerprint fingerprint) { + } + + private record Snapshot(Path path, long size, Fingerprint fingerprint) { + } + + private record Fingerprint(Object fileKey, long size, long modifiedMillis) { + + private static Fingerprint from(BasicFileAttributes attributes) { + return new Fingerprint(attributes.fileKey(), attributes.size(), + attributes.lastModifiedTime().toMillis()); + } + + private boolean matches(BasicFileAttributes attributes) { + return attributes.isRegularFile() && size == attributes.size() + && modifiedMillis == attributes.lastModifiedTime().toMillis() + && (fileKey == null || fileKey.equals(attributes.fileKey())); + } + } + + private enum TarMode { + CREATE(false, true, false), + CREATE_GZIP(true, true, false), + EXTRACT(false, false, false), + EXTRACT_GZIP(true, false, false), + LIST(false, false, true), + LIST_GZIP(true, false, true); + + private final boolean gzip; + private final boolean create; + private final boolean list; + + TarMode(boolean gzip, boolean create, boolean list) { + this.gzip = gzip; + this.create = create; + this.list = list; + } + + private static TarMode parse(String value) { + return switch (value) { + case "-cf" -> CREATE; + case "-czf" -> CREATE_GZIP; + case "-xf" -> EXTRACT; + case "-xzf" -> EXTRACT_GZIP; + case "-tf" -> LIST; + case "-tzf" -> LIST_GZIP; + default -> throw new WorkspaceToolException("SHELL_COMMAND_DENIED", + "tar mode is not allowed.", false); + }; + } + + private boolean gzip() { + return gzip; + } + + private boolean create() { + return create; + } + + private boolean list() { + return list; + } + } + + private static final class EntryIndex { + + private final Set exact = new HashSet<>(); + private final Set files = new HashSet<>(); + private final Map firstDescendant = new HashMap<>(); + + private void add(String rawName, boolean directory) { + String name = rawName; + if (!exact.add(name)) { + throw new WorkspaceToolException("ARCHIVE_DUPLICATE_ENTRY", + "Archive contains a duplicate entry.", false); + } + String ancestor = name; + int separator = ancestor.indexOf('/'); + while (separator >= 0) { + String prefix = ancestor.substring(0, separator); + if (files.contains(prefix)) { + throw new WorkspaceToolException("ARCHIVE_ENTRY_DENIED", + "Archive contains a file/directory hierarchy conflict.", false); + } + firstDescendant.putIfAbsent(prefix, name); + separator = ancestor.indexOf('/', separator + 1); + } + if (!directory && firstDescendant.containsKey(name)) { + throw new WorkspaceToolException("ARCHIVE_ENTRY_DENIED", + "Archive contains a file/directory hierarchy conflict.", false); + } + if (!directory) { + files.add(name); + } + } + } + + private static final class LimitedOutputStream extends FilterOutputStream { + + private final long limit; + private long count; + + private LimitedOutputStream(OutputStream output, long limit) { + super(output); + this.limit = limit; + } + + @Override + public void write(int value) throws IOException { + ensureCapacity(1); + out.write(value); + count++; + } + + @Override + public void write(byte[] value, int offset, int length) throws IOException { + ensureCapacity(length); + out.write(value, offset, length); + count += length; + } + + private void ensureCapacity(int additional) { + if (additional < 0 || count > limit - additional) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Archive output exceeds the single-file limit.", false); + } + } + } + + private static final class OutputCollector { + + private final int limit; + private final StringBuilder value = new StringBuilder(); + private int bytes; + private boolean truncated; + + private OutputCollector(int limit) { + this.limit = limit; + } + + private OutputCollector append(char character) { + return append(String.valueOf(character)); + } + + private OutputCollector append(String text) { + if (truncated || text == null || text.isEmpty()) { + return this; + } + for (int index = 0; index < text.length(); ) { + int codePoint = text.codePointAt(index); + String current = new String(Character.toChars(codePoint)); + int encoded = current.getBytes(StandardCharsets.UTF_8).length; + if (bytes > limit - encoded) { + truncated = true; + break; + } + value.append(current); + bytes += encoded; + index += Character.charCount(codePoint); + } + return this; + } + + private ArchiveExecutionResult result() { + return new ArchiveExecutionResult(value.toString(), truncated); + } + } + + private static final class Stage implements AutoCloseable { + + private final Path root; + + private Stage(Path root) { + this.root = root; + } + + private static Stage create(Path workspaceRoot) throws IOException { + Path parent = workspaceRoot.getParent(); + if (parent == null) { + throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID", + "Workspace root must have a parent directory for archive staging.", false); + } + return new Stage(Files.createTempDirectory(parent, ".easyagents-archive-stage-")); + } + + private Path newFile(String prefix) throws IOException { + return Files.createTempFile(root, prefix + UUID.randomUUID(), ".tmp"); + } + + @Override + public void close() { + try (Stream paths = Files.walk(root)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } catch (IOException error) { + // 调用结果已确定;完整堆栈仅记录到服务端,不向模型泄露宿主路径。 + logger.error("Failed to clean safe archive staging directory", error); + } + } + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeReadFileTool.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeReadFileTool.java new file mode 100644 index 0000000..edb58d7 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeReadFileTool.java @@ -0,0 +1,290 @@ +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 reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.stream.Stream; + +/** + * 与 AgentScope 1.x 文件读取 Schema 兼容的工作区安全工具。 + */ +final class SafeReadFileTool { + + private final ViewTextFileTool viewTextFileTool; + private final ListDirectoryTool listDirectoryTool; + + /** + * 创建文件读取工具组。 + * + * @param pathGuard 路径保护器 + * @param quotaGuard 配额保护器 + */ + SafeReadFileTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) { + this.viewTextFileTool = new ViewTextFileTool(pathGuard, quotaGuard); + this.listDirectoryTool = new ListDirectoryTool(pathGuard, quotaGuard); + } + + /** + * 获取查看文本文件工具。 + * + * @return AgentScope 工具 + */ + AgentTool viewTextFileTool() { + return viewTextFileTool; + } + + /** + * 获取列目录工具。 + * + * @return AgentScope 工具 + */ + AgentTool listDirectoryTool() { + return listDirectoryTool; + } + + /** + * 查看工作区文本文件。 + */ + private static final class ViewTextFileTool implements AgentTool { + + private final WorkspacePathGuard pathGuard; + private final WorkspaceQuotaGuard quotaGuard; + + private ViewTextFileTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) { + this.pathGuard = pathGuard; + this.quotaGuard = quotaGuard; + } + + /** + * 获取工具名。 + * + * @return `view_text_file` + */ + @Override + public String getName() { + return AgentOperateToolAdapter.VIEW_TEXT_FILE_TOOL; + } + + /** + * 获取工具描述。 + * + * @return 工具描述 + */ + @Override + public String getDescription() { + return "View UTF-8 text file content in the workspace with optional line ranges."; + } + + /** + * 获取与 AgentScope 1.x 兼容的参数 Schema。 + * + * @return JSON Schema + */ + @Override + public Map getParameters() { + Map properties = new LinkedHashMap<>(); + properties.put("file_path", Map.of("type", "string", "description", "The target file path")); + properties.put("ranges", Map.of( + "type", "string", + "description", "Optional inclusive line range such as '1,100' or '-100,-1'")); + return Map.of("type", "object", "properties", properties, "required", List.of("file_path")); + } + + /** + * 读取并格式化指定行范围。 + * + * @param param Tool 调用参数 + * @return Tool 结果 + */ + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.fromCallable(() -> view(param)).subscribeOn(Schedulers.boundedElastic()); + } + + private ToolResultBlock view(ToolCallParam param) { + try { + String filePath = requiredString(param, "file_path"); + String ranges = optionalString(param, "ranges"); + Path target = pathGuard.resolveExistingFile(filePath); + WorkspaceTextFiles.RangedLines rangedLines = WorkspaceTextFiles.readUtf8Lines( + target, ranges, quotaGuard.maxReadSize()); + quotaGuard.validateRangeRead(target, rangedLines.readBytes()); + StringBuilder content = new StringBuilder(); + for (int index = 0; index < rangedLines.lines().size(); index++) { + content.append(rangedLines.startLine() + index).append(": ") + .append(rangedLines.lines().get(index)).append('\n'); + } + int endLine = rangedLines.lines().isEmpty() + ? rangedLines.startLine() - 1 + : rangedLines.startLine() + rangedLines.lines().size() - 1; + return ToolResultBlock.text("The content of " + pathGuard.display(target) + + " in lines [" + rangedLines.startLine() + ", " + endLine + "]:\n```\n" + + content + "```"); + } catch (AgentRuntimeException error) { + return WorkspaceToolResults.error(error); + } catch (RuntimeException error) { + return WorkspaceToolResults.error( + new AgentRuntimeException("Unexpected workspace read failure.", error)); + } + } + } + + /** + * 列出工作区单层目录内容。 + */ + private static final class ListDirectoryTool implements AgentTool { + + private final WorkspacePathGuard pathGuard; + private final WorkspaceQuotaGuard quotaGuard; + + private ListDirectoryTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) { + this.pathGuard = pathGuard; + this.quotaGuard = quotaGuard; + } + + /** + * 获取工具名。 + * + * @return `list_directory` + */ + @Override + public String getName() { + return AgentOperateToolAdapter.LIST_DIRECTORY_TOOL; + } + + /** + * 获取工具描述。 + * + * @return 工具描述 + */ + @Override + public String getDescription() { + return "List one level of files and directories using workspace-relative paths."; + } + + /** + * 获取与 AgentScope 1.x 兼容的参数 Schema。 + * + * @return JSON Schema + */ + @Override + public Map getParameters() { + return Map.of( + "type", "object", + "properties", Map.of("dir_path", Map.of( + "type", "string", "description", "The target directory path")), + "required", List.of("dir_path")); + } + + /** + * 列出单层目录。 + * + * @param param Tool 调用参数 + * @return Tool 结果 + */ + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.fromCallable(() -> list(param)).subscribeOn(Schedulers.boundedElastic()); + } + + private ToolResultBlock list(ToolCallParam param) { + try { + Path directory = pathGuard.resolveExistingDirectory(requiredString(param, "dir_path")); + quotaGuard.validateCurrentUsage(); + int limit = quotaGuard.maxDirectoryEntries(); + Comparator displayOrder = Comparator.comparing(pathGuard::display); + PriorityQueue retained = new PriorityQueue<>(limit, displayOrder.reversed()); + long entryCount = 0; + try (Stream stream = Files.list(directory)) { + for (Path entry : (Iterable) stream::iterator) { + entryCount++; + if (retained.size() < limit) { + retained.add(entry); + } else if (displayOrder.compare(entry, retained.peek()) < 0) { + retained.poll(); + retained.add(entry); + } + } + } catch (IOException error) { + throw new WorkspaceToolException("WORKSPACE_IO_FAILED", + "Workspace directory cannot be listed.", true, error); + } + List entries = new ArrayList<>(retained); + entries.sort(displayOrder); + StringBuilder result = new StringBuilder("Contents of directory ") + .append(pathGuard.display(directory)).append(":\n"); + boolean truncated = entryCount > limit; + for (Path entry : entries) { + String type; + long size = 0; + if (Files.isSymbolicLink(entry)) { + type = "blocked-symlink"; + } else if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) { + type = "directory"; + } else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { + type = "file"; + try { + size = Files.size(entry); + } catch (IOException error) { + throw new WorkspaceToolException("WORKSPACE_IO_FAILED", + "Workspace entry size cannot be inspected.", true, error); + } + } else { + type = "blocked-non-regular"; + } + result.append(type).append('\t').append(pathGuard.display(entry)); + if ("file".equals(type)) { + result.append('\t').append(size).append(" bytes"); + } + result.append('\n'); + } + if (truncated) { + result.append("Truncated: true; limit=") + .append(limit).append('\n'); + } + return ToolResultBlock.text(result.toString()); + } catch (AgentRuntimeException error) { + return WorkspaceToolResults.error(error); + } catch (RuntimeException error) { + return WorkspaceToolResults.error( + new AgentRuntimeException("Unexpected workspace listing failure.", error)); + } + } + } + + private static String requiredString(ToolCallParam param, String name) { + Object value = param == null ? null : param.getInput().get(name); + if (!(value instanceof String text) || text.isBlank()) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "Missing required string parameter: " + name, false); + } + return text; + } + + private static String optionalString(ToolCallParam param, String name) { + Object value = param == null ? null : param.getInput().get(name); + if (value == null) { + return null; + } + if (!(value instanceof String text)) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "Invalid string parameter: " + name, false); + } + return text; + } + +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeWriteFileTool.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeWriteFileTool.java new file mode 100644 index 0000000..4bdea07 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/SafeWriteFileTool.java @@ -0,0 +1,325 @@ +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 reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +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.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 与 AgentScope 1.x 文件写入 Schema 兼容的原子工作区工具。 + */ +final class SafeWriteFileTool { + + private final WriteTextFileTool writeTextFileTool; + private final InsertTextFileTool insertTextFileTool; + + /** + * 创建文件写入工具组。 + * + * @param pathGuard 路径保护器 + * @param quotaGuard 配额保护器 + */ + SafeWriteFileTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) { + this.writeTextFileTool = new WriteTextFileTool(pathGuard, quotaGuard); + this.insertTextFileTool = new InsertTextFileTool(pathGuard, quotaGuard); + } + + /** + * 获取写入文本文件工具。 + * + * @return AgentScope 工具 + */ + AgentTool writeTextFileTool() { + return writeTextFileTool; + } + + /** + * 获取插入文本文件工具。 + * + * @return AgentScope 工具 + */ + AgentTool insertTextFileTool() { + return insertTextFileTool; + } + + /** + * 新建、覆盖或范围替换文本文件。 + */ + private static final class WriteTextFileTool implements AgentTool { + + private final WorkspacePathGuard pathGuard; + private final WorkspaceQuotaGuard quotaGuard; + + private WriteTextFileTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) { + this.pathGuard = pathGuard; + this.quotaGuard = quotaGuard; + } + + /** + * 获取工具名。 + * + * @return `write_text_file` + */ + @Override + public String getName() { + return AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL; + } + + /** + * 获取工具描述。 + * + * @return 工具描述 + */ + @Override + public String getDescription() { + return "Create, overwrite, or replace an inclusive line range in a UTF-8 workspace file."; + } + + /** + * 获取与 AgentScope 1.x 兼容的参数 Schema。 + * + * @return JSON Schema + */ + @Override + public Map getParameters() { + Map properties = new LinkedHashMap<>(); + properties.put("file_path", Map.of("type", "string", "description", "The target file path")); + properties.put("content", Map.of("type", "string", "description", "The content to be written")); + properties.put("ranges", Map.of( + "type", "string", + "description", "Optional inclusive replacement range such as '1,5'")); + return Map.of( + "type", "object", + "properties", properties, + "required", List.of("file_path", "content")); + } + + /** + * 原子写入文件。 + * + * @param param Tool 调用参数 + * @return Tool 结果 + */ + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.fromCallable(() -> write(param)).subscribeOn(Schedulers.boundedElastic()); + } + + private ToolResultBlock write(ToolCallParam param) { + try { + String filePath = requiredString(param, "file_path"); + String content = requiredStringAllowEmpty(param, "content"); + String ranges = optionalString(param, "ranges"); + Path target = pathGuard.resolveForWrite(filePath); + byte[] bytes; + if (ranges == null || ranges.isBlank() || !Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + bytes = content.getBytes(StandardCharsets.UTF_8); + } else { + quotaGuard.validateFullRead(target); + List lines = splitLines(WorkspaceTextFiles.readUtf8(target)); + int[] range = parseReplacementRange(ranges, lines.size()); + List updated = new ArrayList<>(); + updated.addAll(lines.subList(0, range[0] - 1)); + updated.addAll(splitContentLines(content)); + updated.addAll(lines.subList(range[1], lines.size())); + bytes = String.join("\n", updated).getBytes(StandardCharsets.UTF_8); + } + quotaGuard.validateWrite(target, bytes.length); + WorkspaceTextFiles.atomicWrite(pathGuard, target, bytes); + return ToolResultBlock.text("Write " + pathGuard.display(target) + " successfully."); + } catch (AgentRuntimeException error) { + return WorkspaceToolResults.error(error); + } catch (RuntimeException error) { + return WorkspaceToolResults.error( + new AgentRuntimeException("Unexpected workspace write failure.", error)); + } + } + } + + /** + * 在指定 1-based 行号插入文本。 + */ + private static final class InsertTextFileTool implements AgentTool { + + private final WorkspacePathGuard pathGuard; + private final WorkspaceQuotaGuard quotaGuard; + + private InsertTextFileTool(WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) { + this.pathGuard = pathGuard; + this.quotaGuard = quotaGuard; + } + + /** + * 获取工具名。 + * + * @return `insert_text_file` + */ + @Override + public String getName() { + return AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL; + } + + /** + * 获取工具描述。 + * + * @return 工具描述 + */ + @Override + public String getDescription() { + return "Insert UTF-8 content at a 1-based line number in an existing workspace file."; + } + + /** + * 获取与 AgentScope 1.x 兼容的参数 Schema。 + * + * @return JSON Schema + */ + @Override + public Map getParameters() { + Map properties = new LinkedHashMap<>(); + properties.put("file_path", Map.of("type", "string", "description", "The target file path")); + properties.put("content", Map.of("type", "string", "description", "The content to be inserted")); + properties.put("line_number", Map.of( + "type", "integer", + "description", "The 1-based line number where content is inserted")); + return Map.of( + "type", "object", + "properties", properties, + "required", List.of("file_path", "content", "line_number")); + } + + /** + * 原子插入文件内容。 + * + * @param param Tool 调用参数 + * @return Tool 结果 + */ + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.fromCallable(() -> insert(param)).subscribeOn(Schedulers.boundedElastic()); + } + + private ToolResultBlock insert(ToolCallParam param) { + try { + String filePath = requiredString(param, "file_path"); + String content = requiredStringAllowEmpty(param, "content"); + int lineNumber = requiredInteger(param, "line_number"); + Path target = pathGuard.resolveExistingFile(filePath); + quotaGuard.validateFullRead(target); + List lines = splitLines(WorkspaceTextFiles.readUtf8(target)); + if (lineNumber < 1 || lineNumber > lines.size() + 1) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "line_number is outside the valid range [1, " + + (lines.size() + 1) + "].", false); + } + List updated = new ArrayList<>(lines); + updated.addAll(lineNumber - 1, splitContentLines(content)); + byte[] bytes = String.join("\n", updated).getBytes(StandardCharsets.UTF_8); + quotaGuard.validateWrite(target, bytes.length); + WorkspaceTextFiles.atomicWrite(pathGuard, target, bytes); + return ToolResultBlock.text("Insert content into " + pathGuard.display(target) + + " at line " + lineNumber + " successfully."); + } catch (AgentRuntimeException error) { + return WorkspaceToolResults.error(error); + } catch (RuntimeException error) { + return WorkspaceToolResults.error( + new AgentRuntimeException("Unexpected workspace insert failure.", error)); + } + } + } + + private static String requiredString(ToolCallParam param, String name) { + String text = requiredStringAllowEmpty(param, name); + if (text.isBlank()) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "Missing required string parameter: " + name, false); + } + return text; + } + + private static String requiredStringAllowEmpty(ToolCallParam param, String name) { + Object value = param == null ? null : param.getInput().get(name); + if (!(value instanceof String text)) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "Missing required string parameter: " + name, false); + } + return text; + } + + private static String optionalString(ToolCallParam param, String name) { + Object value = param == null ? null : param.getInput().get(name); + if (value == null) { + return null; + } + if (!(value instanceof String text)) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "Invalid string parameter: " + name, false); + } + return text; + } + + private static int requiredInteger(ToolCallParam param, String name) { + Object value = param == null ? null : param.getInput().get(name); + if (!(value instanceof Number number)) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "Missing required integer parameter: " + name, false); + } + return number.intValue(); + } + + private static int[] parseReplacementRange(String ranges, int lineCount) { + String normalized = ranges.trim().replace("[", "").replace("]", ""); + String[] parts = normalized.split(",", -1); + if (parts.length != 2) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "Invalid range format. Expected 'start,end'.", false); + } + try { + int start = Integer.parseInt(parts[0].trim()); + int end = Integer.parseInt(parts[1].trim()); + if (start < 1 || end < start || start > lineCount || end > lineCount) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "Replacement range is outside the file.", false); + } + return new int[]{start, end}; + } catch (NumberFormatException error) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "Invalid range format. Expected integer line numbers.", false, error); + } + } + + private static List splitLines(String content) { + if (content.isEmpty()) { + return new ArrayList<>(); + } + String normalized = content.replace("\r\n", "\n").replace('\r', '\n'); + String[] values = normalized.split("\n", -1); + int length = values.length; + if (length > 0 && values[length - 1].isEmpty()) { + length--; + } + List lines = new ArrayList<>(length); + for (int index = 0; index < length; index++) { + lines.add(values[index]); + } + return lines; + } + + private static List splitContentLines(String content) { + if (content.isEmpty()) { + return List.of(""); + } + return List.of(content.replace("\r\n", "\n").replace('\r', '\n').split("\n", -1)); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ShellCommandOptionValidator.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ShellCommandOptionValidator.java new file mode 100644 index 0000000..3d39dcd --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ShellCommandOptionValidator.java @@ -0,0 +1,601 @@ +package com.easyagents.agent.runtime.tool.operate; + +import com.easyagents.agent.runtime.AgentRuntimeException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 白名单命令的命令级选项与路径参数校验器。 + * + *

入口命令白名单不足以阻止工具通过合法命令的扩展选项启动子进程或访问第二路径。 + * 该校验器集中关闭这些二级执行入口,并对已知文件参数执行工作区路径保护。 + */ +final class ShellCommandOptionValidator { + + private static final Pattern AWK_CODE_EXECUTION = Pattern.compile( + "(?is).*(\\bsystem\\s*\\(|\\bgetline\\b|\\bENVIRON\\b|@load\\b|\\bextension\\s*\\().*"); + private static final Pattern SED_SIDE_EFFECT_COMMAND = Pattern.compile( + "(?is).*(^|[;{}\\n])\\s*(?:(?:\\d+|\\$|/([^/\\n\\\\]|\\\\.)*/)(?:\\s*,\\s*" + + "(?:\\d+|\\$|/([^/\\n\\\\]|\\\\.)*/))?\\s*)?[eErRwW](?:\\s|$).*"); + private static final Pattern JQ_EXTERNAL_INPUT = Pattern.compile( + "(?is).*(\\b(import|include|module|input|inputs|env)\\b|\\$ENV\\b).*"); + + private final WorkspacePathGuard pathGuard; + + /** + * 创建命令选项校验器。 + * + * @param pathGuard 工作区路径保护器 + */ + ShellCommandOptionValidator(WorkspacePathGuard pathGuard) { + this.pathGuard = pathGuard; + } + + /** + * 校验命令专属的子执行入口、文件选项和路径操作数。 + * + * @param arguments 已完成安全分词的命令参数 + */ + void validate(List arguments) { + String command = arguments.get(0); + switch (command) { + case "ls" -> validateList(arguments); + case "awk" -> validateAwk(arguments); + case "sed" -> validateSed(arguments); + case "rg" -> validateRipgrep(arguments); + case "grep" -> validateGrep(arguments); + case "jq" -> validateJq(arguments); + case "sort" -> validateSort(arguments); + case "uniq" -> validateUniq(arguments); + case "diff", "cmp" -> validateExistingOperands(arguments); + case "du" -> validateDiskUsage(arguments); + case "tree" -> validateTree(arguments); + case "cp" -> validateCopy(arguments); + case "mkdir", "touch", "mv", "rm" -> validateAllOperands(arguments); + case "wc" -> validateWordCount(arguments); + case "file" -> validateFile(arguments); + case "sha256sum", "shasum" -> validateChecksum(arguments); + case "tail" -> validateTail(arguments); + case "pandoc" -> validatePandoc(arguments); + case "soffice" -> validateSoffice(arguments); + case "pdftoppm" -> validatePdfToPpm(arguments); + case "pdfinfo" -> validatePdfInfo(arguments); + case "pdftotext" -> validatePdfToText(arguments); + case "pdfimages" -> validatePdfImages(arguments); + case "qpdf" -> validateQpdf(arguments); + case "cat", "head", "cut", "stat" -> + validateExistingOperands(arguments); + default -> { + // pwd/date/tr/basename/dirname/python/python3/node 没有额外的子执行选项;脚本入口由外层单独校验。 + } + } + } + + private void validateDiskUsage(List arguments) { + rejectOptions(arguments, Set.of( + "--files0-from", "--exclude-from", "-L", "--dereference", "-H", "-D", + "--dereference-args")); + validateExistingOperands(arguments); + } + + private void validateTree(List arguments) { + rejectOptions(arguments, Set.of( + "-l", "--follow-links", "-o", "--fromfile", "--gitfile", "--info")); + validateExistingOperands(arguments); + } + + private void validatePandoc(List arguments) { + rejectOptions(arguments, Set.of( + "-F", "--filter", "-L", "--lua-filter", "-d", "--defaults", "--data-dir", + "--resource-path", "--extract-media", "--pdf-engine", "--pdf-engine-opt")); + for (String argument : arguments.subList(1, arguments.size())) { + if (isAttachedShortOption(argument, "-o")) { + throw new AgentRuntimeException( + "pandoc attached output paths are not allowed; use -o followed by a workspace path."); + } + } + validateFollowingFileOptions(arguments, Set.of( + "--template", "--metadata-file", "--reference-doc", "--syntax-definition", + "--include-in-header", "--include-before-body", "--include-after-body", + "--bibliography", "--csl", "--citation-abbreviations"), false); + validateFollowingFileOptions(arguments, Set.of("-o", "--output", "--log"), true); + validateExistingOperands(arguments); + } + + private void validateSoffice(List arguments) { + String format = null; + String outputDirectory = null; + List inputs = new ArrayList<>(); + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + String option = optionName(argument); + if (Set.of("--headless", "--nologo", "--nodefault", "--nolockcheck", "--norestore") + .contains(option)) { + continue; + } + if ("--convert-to".equals(option)) { + format = optionValue(arguments, index); + if (!argument.contains("=")) { + index++; + } + continue; + } + if ("--outdir".equals(option)) { + outputDirectory = optionValue(arguments, index); + if (!argument.contains("=")) { + index++; + } + continue; + } + if (argument.startsWith("-")) { + throw new AgentRuntimeException("soffice option is not allowed: " + option); + } + inputs.add(argument); + } + if (format == null || outputDirectory == null || inputs.isEmpty()) { + throw new AgentRuntimeException( + "soffice requires --convert-to, --outdir, and at least one workspace input file."); + } + String normalizedFormat = format.split(":", 2)[0].toLowerCase(java.util.Locale.ROOT); + if (!Set.of("pdf", "docx", "xlsx", "pptx", "odt", "ods", "odp", "html", "txt", "csv") + .contains(normalizedFormat)) { + throw new AgentRuntimeException("soffice output format is not allowed: " + normalizedFormat); + } + pathGuard.resolveExistingDirectory(outputDirectory); + inputs.forEach(pathGuard::resolveExistingFile); + } + + private void validatePdfToPpm(List arguments) { + List operands = pdfOperands(arguments, Set.of( + "-f", "-l", "-r", "-rx", "-ry", "-scale-to", "-scale-to-x", "-scale-to-y", + "-x", "-y", "-W", "-H", "-sz")); + if (operands.size() != 2) { + throw new AgentRuntimeException("pdftoppm requires one PDF input and one output prefix."); + } + pathGuard.resolveExistingFile(operands.get(0)); + pathGuard.resolveCommandPath(operands.get(1)); + } + + private void validatePdfInfo(List arguments) { + rejectOptions(arguments, Set.of("-opw", "-upw")); + List operands = pdfOperands(arguments, Set.of("-f", "-l")); + if (operands.size() != 1) { + throw new AgentRuntimeException("pdfinfo requires exactly one workspace PDF input."); + } + pathGuard.resolveExistingFile(operands.get(0)); + } + + private void validatePdfToText(List arguments) { + rejectOptions(arguments, Set.of("-opw", "-upw")); + List operands = pdfOperands(arguments, Set.of( + "-f", "-l", "-r", "-x", "-y", "-W", "-H", "-enc", "-eol")); + if (operands.size() < 1 || operands.size() > 2) { + throw new AgentRuntimeException("pdftotext requires one PDF input and an optional output file."); + } + pathGuard.resolveExistingFile(operands.get(0)); + if (operands.size() == 2 && !"-".equals(operands.get(1))) { + pathGuard.resolveCommandPath(operands.get(1)); + } + } + + private void validatePdfImages(List arguments) { + rejectOptions(arguments, Set.of("-opw", "-upw")); + List operands = pdfOperands(arguments, Set.of("-f", "-l", "-jpegopt")); + if (operands.size() != 2) { + throw new AgentRuntimeException("pdfimages requires one PDF input and one output prefix."); + } + pathGuard.resolveExistingFile(operands.get(0)); + pathGuard.resolveCommandPath(operands.get(1)); + } + + private void validateQpdf(List arguments) { + rejectOptions(arguments, Set.of( + "--replace-input", "--password-file", "--encryption-file-password", + "--copy-attachments-from", "--overlay", "--underlay", "--json-input", + "--job-json-file")); + for (String argument : arguments.subList(1, arguments.size())) { + if (argument.startsWith("@")) { + throw new AgentRuntimeException("qpdf response files are not allowed."); + } + } + validateExistingOperands(arguments); + } + + private List pdfOperands(List arguments, Set optionsWithValues) { + List result = new ArrayList<>(); + boolean endOfOptions = false; + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + if (!endOfOptions && "--".equals(argument)) { + endOfOptions = true; + continue; + } + if (!endOfOptions && argument.startsWith("-")) { + String option = optionName(argument); + if (optionsWithValues.contains(option) && !argument.contains("=")) { + if (++index >= arguments.size()) { + throw new AgentRuntimeException("PDF command option requires a value: " + option); + } + } + continue; + } + result.add(argument); + } + return result; + } + + private void validateFollowingFileOptions(List arguments, + Set fileOptions, + boolean writable) { + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + String option = optionName(argument); + if (!fileOptions.contains(option)) { + continue; + } + String path = optionValue(arguments, index); + if (writable) { + pathGuard.resolveCommandPath(path); + } else { + pathGuard.resolveExistingFile(path); + } + if (!argument.contains("=")) { + index++; + } + } + } + + private void validateAwk(List arguments) { + rejectOptions(arguments, Set.of( + "-f", "--file", "-e", "--exec", "-i", "--include", "-l", "--load", "-W", + "-d", "--dump-variables", "-o", "--pretty-print", "-p", "--profile")); + for (String argument : operands(arguments)) { + if (AWK_CODE_EXECUTION.matcher(argument).matches()) { + throw new AgentRuntimeException("awk sub-process and external input features are not allowed."); + } + } + validateExistingOperandsSkippingFirst(arguments); + } + + private void validateSed(List arguments) { + List expressions = new ArrayList<>(); + List files = new ArrayList<>(); + boolean endOfOptions = false; + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + if (!endOfOptions && "--".equals(argument)) { + endOfOptions = true; + continue; + } + if (!endOfOptions && (argument.equals("-i") || argument.startsWith("-i") + || argument.startsWith("--in-place") || argument.equals("--follow-symlinks") + || argument.startsWith("-f") || argument.startsWith("--file"))) { + throw new AgentRuntimeException("sed in-place, external script, and symlink-following options are not allowed."); + } + if (!endOfOptions && ("-e".equals(argument) || "--expression".equals(argument))) { + if (++index >= arguments.size()) { + throw new AgentRuntimeException("sed expression option requires a value."); + } + expressions.add(arguments.get(index)); + continue; + } + if (!endOfOptions && argument.startsWith("--expression=")) { + expressions.add(argument.substring("--expression=".length())); + continue; + } + if (!endOfOptions && argument.startsWith("-") && !isSafeSedFlag(argument)) { + throw new AgentRuntimeException("sed option is not allowed."); + } + if (!endOfOptions && argument.startsWith("-")) { + continue; + } + if (expressions.isEmpty()) { + expressions.add(argument); + } else { + files.add(argument); + } + } + if (expressions.isEmpty()) { + throw new AgentRuntimeException("sed requires an inline expression."); + } + for (String expression : expressions) { + if (SED_SIDE_EFFECT_COMMAND.matcher(expression).matches() + || containsUnsafeSubstitutionFlag(expression)) { + throw new AgentRuntimeException("sed execute/read/write commands are not allowed."); + } + } + for (String file : files) { + validateExistingPath(file); + } + } + + private void validateRipgrep(List arguments) { + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + String option = optionName(argument); + if (Set.of("--pre", "--pre-glob", "--hostname-bin", "--search-zip").contains(option) + || isShortOptionPresent(argument, 'z') || "--follow".equals(option) + || isShortOptionPresent(argument, 'L')) { + throw new AgentRuntimeException( + "rg preprocessors, archive search, and symlink-following options are not allowed."); + } + if (Set.of("-f", "--file", "--ignore-file").contains(option) + || isAttachedShortOption(argument, "-f")) { + String path = attachedOrFollowingValue(arguments, index, "-f"); + validateExistingPath(path); + if (!argument.contains("=") && !isAttachedShortOption(argument, "-f")) { + index++; + } + } + } + validateExistingOperands(arguments); + } + + private void validateGrep(List arguments) { + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + String option = optionName(argument); + if (isShortOptionPresent(argument, 'R') || "--dereference-recursive".equals(option)) { + throw new AgentRuntimeException("grep symlink-following recursion is not allowed."); + } + if (Set.of("-f", "--file", "--exclude-from").contains(option) + || isAttachedShortOption(argument, "-f")) { + String path = attachedOrFollowingValue(arguments, index, "-f"); + validateExistingPath(path); + if (!argument.contains("=") && !isAttachedShortOption(argument, "-f")) { + index++; + } + } + } + validateExistingOperands(arguments); + } + + private void validateJq(List arguments) { + rejectOptions(arguments, Set.of("-f", "--from-file", "-L", "--library-path", "--run-tests")); + for (String operand : operands(arguments)) { + if (JQ_EXTERNAL_INPUT.matcher(operand).matches()) { + throw new AgentRuntimeException("jq module, environment, and external input functions are not allowed."); + } + } + for (int index = 1; index < arguments.size(); index++) { + String option = optionName(arguments.get(index)); + if (Set.of("--argfile", "--slurpfile", "--rawfile").contains(option)) { + if (index + 2 >= arguments.size()) { + throw new AgentRuntimeException("jq file option requires a variable name and workspace file."); + } + validateExistingPath(arguments.get(index + 2)); + index += 2; + } + } + validateExistingOperandsSkippingFirst(arguments); + } + + private void validateSort(List arguments) { + rejectOptions(arguments, Set.of("-o", "--output", "--compress-program", "-T", "--temporary-directory")); + for (int index = 1; index < arguments.size(); index++) { + String option = optionName(arguments.get(index)); + if ("--random-source".equals(option)) { + String path = optionValue(arguments, index); + validateExistingPath(path); + if (!arguments.get(index).contains("=")) { + index++; + } + } + } + validateExistingOperands(arguments); + } + + private void validateUniq(List arguments) { + List operands = operands(arguments); + if (operands.size() > 1) { + throw new AgentRuntimeException("uniq output-file operand is not allowed; use write_text_file instead."); + } + if (!operands.isEmpty()) { + validateExistingPath(operands.get(0)); + } + } + + private void validateCopy(List arguments) { + for (String argument : arguments) { + if (isShortOptionPresent(argument, 'L') || isShortOptionPresent(argument, 'H') + || isShortOptionPresent(argument, 'l') || isShortOptionPresent(argument, 's') + || Set.of("--dereference", "--link", "--symbolic-link") + .contains(optionName(argument))) { + throw new AgentRuntimeException("cp link creation and symlink-following options are not allowed."); + } + } + validateAllOperands(arguments); + } + + private void validateTail(List arguments) { + for (String argument : arguments.subList(1, arguments.size())) { + String option = optionName(argument); + if (isShortOptionPresent(argument, 'f') || isShortOptionPresent(argument, 'F') + || "--follow".equals(option)) { + throw new AgentRuntimeException("tail follow mode is not allowed."); + } + } + validateExistingOperands(arguments); + } + + private void validateList(List arguments) { + for (String argument : arguments.subList(1, arguments.size())) { + String option = optionName(argument); + if (isShortOptionPresent(argument, 'L') || "--dereference".equals(option) + || "--dereference-command-line".equals(option) + || "--dereference-command-line-symlink-to-dir".equals(option)) { + throw new AgentRuntimeException("ls symlink-following options are not allowed."); + } + } + validateExistingOperands(arguments); + } + + private void validateWordCount(List arguments) { + rejectOptions(arguments, Set.of("--files0-from")); + validateExistingOperands(arguments); + } + + private void validateFile(List arguments) { + rejectOptions(arguments, Set.of("-f", "--files-from", "-C", "--compile")); + validateExistingOperands(arguments); + } + + private void validateChecksum(List arguments) { + rejectOptions(arguments, Set.of("-c", "--check")); + validateExistingOperands(arguments); + } + + private void validateAllOperands(List arguments) { + for (String operand : operands(arguments)) { + pathGuard.resolveCommandPath(operand); + } + } + + private void validateExistingOperands(List arguments) { + for (String operand : operands(arguments)) { + validateExistingPathIfPresent(operand); + } + } + + private void validateExistingOperandsSkippingFirst(List arguments) { + List operands = operands(arguments); + for (int index = 1; index < operands.size(); index++) { + validateExistingPathIfPresent(operands.get(index)); + } + } + + private void validateExistingPathIfPresent(String value) { + java.nio.file.Path candidate = pathGuard.root().resolve(value).normalize(); + if (java.nio.file.Files.exists(candidate, java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + pathGuard.resolveExistingEntry(value); + } + } + + private void validateExistingPath(String value) { + pathGuard.resolveExistingFile(value); + } + + private void rejectOptions(List arguments, Set rejected) { + for (String argument : arguments.subList(1, arguments.size())) { + String option = optionName(argument); + if (rejected.contains(option) || rejected.stream() + .filter(value -> value.startsWith("-") && !value.startsWith("--") && value.length() == 2) + .anyMatch(value -> isAttachedShortOption(argument, value))) { + throw new AgentRuntimeException("Command option is not allowed: " + option); + } + } + } + + private List operands(List arguments) { + List operands = new ArrayList<>(); + boolean endOfOptions = false; + for (int index = 1; index < arguments.size(); index++) { + String argument = arguments.get(index); + if (!endOfOptions && "--".equals(argument)) { + endOfOptions = true; + continue; + } + if (!endOfOptions && argument.startsWith("-")) { + continue; + } + operands.add(argument); + } + return operands; + } + + private String optionName(String argument) { + int equals = argument.indexOf('='); + return equals < 0 ? argument : argument.substring(0, equals); + } + + private String optionValue(List arguments, int optionIndex) { + String argument = arguments.get(optionIndex); + int equals = argument.indexOf('='); + if (equals >= 0) { + String value = argument.substring(equals + 1); + if (value.isBlank()) { + throw new AgentRuntimeException("Command file option requires a value."); + } + return value; + } + if (optionIndex + 1 >= arguments.size()) { + throw new AgentRuntimeException("Command file option requires a value."); + } + return arguments.get(optionIndex + 1); + } + + private String attachedOrFollowingValue(List arguments, int optionIndex, String shortOption) { + String argument = arguments.get(optionIndex); + if (isAttachedShortOption(argument, shortOption)) { + return argument.substring(shortOption.length()); + } + return optionValue(arguments, optionIndex); + } + + private boolean isAttachedShortOption(String argument, String option) { + return argument.startsWith(option) && argument.length() > option.length() + && !argument.startsWith("--"); + } + + private boolean isShortOptionPresent(String argument, char option) { + return argument.startsWith("-") && !argument.startsWith("--") + && argument.length() > 1 && argument.substring(1).indexOf(option) >= 0; + } + + private boolean containsUnsafeSubstitutionFlag(String expression) { + for (int index = 0; index + 1 < expression.length(); index++) { + if (expression.charAt(index) != 's' || Character.isLetterOrDigit(expression.charAt(index + 1))) { + continue; + } + char delimiter = expression.charAt(index + 1); + int patternEnd = findUnescaped(expression, delimiter, index + 2); + if (patternEnd < 0) { + continue; + } + int replacementEnd = findUnescaped(expression, delimiter, patternEnd + 1); + if (replacementEnd < 0) { + continue; + } + for (int flagIndex = replacementEnd + 1; flagIndex < expression.length(); flagIndex++) { + char flag = expression.charAt(flagIndex); + if (flag == ';' || flag == '\n' || flag == '}') { + break; + } + if (flag == 'e' || flag == 'w' || flag == 'W') { + return true; + } + if (!Character.isWhitespace(flag) && !Character.isDigit(flag) + && "gIpMm".indexOf(flag) < 0) { + break; + } + } + } + return false; + } + + private int findUnescaped(String value, char delimiter, int start) { + boolean escaped = false; + for (int index = start; index < value.length(); index++) { + char current = value.charAt(index); + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == delimiter) { + return index; + } + } + return -1; + } + + private boolean isSafeSedFlag(String argument) { + if (Set.of("-n", "--quiet", "--silent", "-E", "-r", "--regexp-extended", "--sandbox") + .contains(argument)) { + return true; + } + return argument.matches("-[nEr]+"); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ShellProcessGroupSupport.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ShellProcessGroupSupport.java new file mode 100644 index 0000000..e3c98b3 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/ShellProcessGroupSupport.java @@ -0,0 +1,150 @@ +package com.easyagents.agent.runtime.tool.operate; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Linux Shell 独立会话与进程组清理支持。 + * + *

Linux 使用受信任的 util-linux {@code setsid} 创建独立会话,并通过系统 {@code kill} + * 向负 PGID 发送信号。JDK 17 没有可移植的 killpg API,非 Linux 平台保留 ProcessHandle + * 后代跟踪降级;脚本显式创建第二个会话仍属于无 OS 沙箱时无法消除的边界。 + */ +final class ShellProcessGroupSupport { + + private static final Logger logger = LoggerFactory.getLogger(ShellProcessGroupSupport.class); + private static final List SETSID_CANDIDATES = List.of( + Path.of("/usr/bin/setsid"), Path.of("/bin/setsid")); + private static final List KILL_CANDIDATES = List.of( + Path.of("/bin/kill"), Path.of("/usr/bin/kill")); + + private final Path setsid; + private final Path kill; + + private ShellProcessGroupSupport(Path setsid, Path kill) { + this.setsid = setsid; + this.kill = kill; + } + + /** + * 检测当前平台的进程组能力。 + * + * @return Linux 进程组支持或可移植降级实例 + */ + static ShellProcessGroupSupport detect() { + String osName = System.getProperty("os.name", ""); + if (!isLinux(osName)) { + return new ShellProcessGroupSupport(null, null); + } + return detect(osName, firstExecutable(SETSID_CANDIDATES), firstExecutable(KILL_CANDIDATES)); + } + + /** + * 使用显式路径检测平台能力,供启动校验测试使用。 + * + * @param osName 操作系统名称 + * @param setsidPath setsid 路径,可空 + * @param killPath kill 路径,可空 + * @return 检测结果 + */ + static ShellProcessGroupSupport detect(String osName, Path setsidPath, Path killPath) { + if (!isLinux(osName)) { + return new ShellProcessGroupSupport(null, null); + } + if (!isTrustedExecutable(setsidPath) || !isTrustedExecutable(killPath)) { + throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID", + "Linux controlled shell requires executable setsid and kill utilities.", false); + } + return new ShellProcessGroupSupport(setsidPath.toAbsolutePath().normalize(), + killPath.toAbsolutePath().normalize()); + } + + /** + * 返回是否启用 Linux 独立进程组。 + * + * @return 启用时为 true + */ + boolean enabled() { + return setsid != null && kill != null; + } + + /** + * 为 Linux 命令增加受信任 setsid 前缀。 + * + * @param command 已校验命令参数 + * @return 实际 ProcessBuilder 参数 + */ + List wrap(List command) { + if (!enabled()) { + return command; + } + List wrapped = new ArrayList<>(command.size() + 1); + wrapped.add(setsid.toString()); + wrapped.addAll(command); + return wrapped; + } + + /** + * 对独立进程组发送 TERM,随后发送 KILL 清理残留成员。 + * + * @param processGroupId setsid 进程 PID,同时也是 PGID + */ + void terminate(long processGroupId) { + if (!enabled() || processGroupId <= 1) { + return; + } + if (!signal("-TERM", processGroupId)) { + return; + } + try { + Thread.sleep(100); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } + signal("-KILL", processGroupId); + } + + private boolean signal(String signal, long processGroupId) { + try { + Process process = new ProcessBuilder( + kill.toString(), signal, "--", "-" + processGroupId) + .redirectInput(ProcessBuilder.Redirect.from(Path.of("/dev/null").toFile())) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + if (!process.waitFor(500, TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + return false; + } + return process.exitValue() == 0; + } catch (IOException error) { + logger.error("Failed to signal controlled shell process group", error); + return false; + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while signaling controlled shell process group", error); + return false; + } + } + + private static boolean isLinux(String osName) { + return osName != null && osName.toLowerCase(Locale.ROOT).contains("linux"); + } + + private static Path firstExecutable(List candidates) { + return candidates.stream().filter(ShellProcessGroupSupport::isTrustedExecutable) + .findFirst().orElse(null); + } + + private static boolean isTrustedExecutable(Path path) { + return path != null && path.isAbsolute() && Files.isRegularFile(path) && Files.isExecutable(path); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/UnifiedPatchParser.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/UnifiedPatchParser.java new file mode 100644 index 0000000..032072b --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/UnifiedPatchParser.java @@ -0,0 +1,287 @@ +package com.easyagents.agent.runtime.tool.operate; + +import com.easyagents.agent.runtime.AgentRuntimeException; +import com.easyagents.agent.runtime.tool.operate.ApplyPatchTool.DiffLine; +import com.easyagents.agent.runtime.tool.operate.ApplyPatchTool.FilePatch; +import com.easyagents.agent.runtime.tool.operate.ApplyPatchTool.Hunk; +import com.easyagents.agent.runtime.tool.operate.ApplyPatchTool.PatchType; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * `*** Begin Patch` 和标准 unified diff 解析器。 + */ +final class UnifiedPatchParser { + + private static final Pattern HUNK_HEADER = Pattern.compile( + "^@@(?:\\s+-(\\d+)(?:,\\d+)?\\s+\\+\\d+(?:,\\d+)?\\s+@@.*)?$"); + + private UnifiedPatchParser() { + } + + /** + * 解析补丁文本。 + * + * @param patch 补丁文本 + * @return 有序文件补丁 + */ + static List parse(String patch) { + String normalized = patch.replace("\r\n", "\n").replace('\r', '\n'); + List lines = List.of(normalized.split("\n", -1)); + if (!lines.isEmpty() && "*** Begin Patch".equals(lines.get(0))) { + return parseEnvelope(lines); + } + return parseUnified(lines); + } + + /** + * 将单文件补丁应用到当前文本。 + * + * @param patch 单文件补丁 + * @param current 当前 UTF-8 文本 + * @return 修改后文本 + */ + static String apply(FilePatch patch, String current) { + boolean trailingNewline = patch.type() == PatchType.ADD || current.endsWith("\n") || current.endsWith("\r"); + List content = splitDocument(current); + if (patch.type() == PatchType.DELETE && patch.hunks().isEmpty()) { + return ""; + } + for (Hunk hunk : patch.hunks()) { + List oldLines = hunk.lines().stream() + .filter(line -> line.kind() != '+') + .map(DiffLine::text) + .toList(); + List newLines = hunk.lines().stream() + .filter(line -> line.kind() != '-') + .map(DiffLine::text) + .toList(); + int position = locateUnique(content, oldLines, hunk.oldStart()); + for (int index = 0; index < oldLines.size(); index++) { + if (!content.get(position + index).equals(oldLines.get(index))) { + throw new WorkspaceToolException("PATCH_CONFLICT", + "Patch hunk context does not match the target file.", false); + } + } + content.subList(position, position + oldLines.size()).clear(); + content.addAll(position, newLines); + } + String result = String.join("\n", content); + if (patch.type() == PatchType.DELETE && !result.isEmpty()) { + throw new WorkspaceToolException("PATCH_CONFLICT", + "Delete patch does not match the complete target file.", false); + } + return trailingNewline && !content.isEmpty() ? result + "\n" : result; + } + + private static List parseEnvelope(List lines) { + List patches = new ArrayList<>(); + Set targets = new LinkedHashSet<>(); + int index = 1; + while (index < lines.size()) { + String line = lines.get(index); + if ("*** End Patch".equals(line)) { + return patches; + } + PatchType type; + String path; + if (line.startsWith("*** Add File: ")) { + type = PatchType.ADD; + path = line.substring("*** Add File: ".length()).trim(); + } else if (line.startsWith("*** Update File: ")) { + type = PatchType.UPDATE; + path = line.substring("*** Update File: ".length()).trim(); + } else if (line.startsWith("*** Delete File: ")) { + type = PatchType.DELETE; + path = line.substring("*** Delete File: ".length()).trim(); + } else if (line.isEmpty()) { + index++; + continue; + } else { + throw patchInvalid("Invalid patch section header."); + } + if (path.isBlank() || !targets.add(path)) { + throw patchInvalid("Patch target is empty or duplicated."); + } + index++; + List body = new ArrayList<>(); + while (index < lines.size() && !lines.get(index).startsWith("*** ")) { + body.add(lines.get(index++)); + } + if (!body.isEmpty() && body.get(body.size() - 1).isEmpty()) { + body.remove(body.size() - 1); + } + patches.add(buildFilePatch(type, path, body)); + } + throw patchInvalid("Patch is missing *** End Patch."); + } + + private static List parseUnified(List lines) { + List patches = new ArrayList<>(); + Set targets = new LinkedHashSet<>(); + int index = 0; + while (index < lines.size()) { + if (!lines.get(index).startsWith("--- ")) { + if (lines.get(index).isEmpty()) { + index++; + continue; + } + throw patchInvalid("Invalid unified diff: expected '---' header."); + } + String oldPath = headerPath(lines.get(index++).substring(4)); + if (index >= lines.size() || !lines.get(index).startsWith("+++ ")) { + throw patchInvalid("Invalid unified diff: expected '+++' header."); + } + String newPath = headerPath(lines.get(index++).substring(4)); + PatchType type = "/dev/null".equals(oldPath) ? PatchType.ADD + : "/dev/null".equals(newPath) ? PatchType.DELETE : PatchType.UPDATE; + String path = type == PatchType.DELETE ? stripPrefix(oldPath) : stripPrefix(newPath); + if (path.isBlank() || !targets.add(path)) { + throw patchInvalid("Patch target is empty or duplicated."); + } + List body = new ArrayList<>(); + while (index < lines.size() && !lines.get(index).startsWith("--- ")) { + body.add(lines.get(index++)); + } + if (!body.isEmpty() && body.get(body.size() - 1).isEmpty()) { + body.remove(body.size() - 1); + } + patches.add(buildFilePatch(type, path, body)); + } + return patches; + } + + private static FilePatch buildFilePatch(PatchType type, String path, List body) { + if (type == PatchType.DELETE && body.isEmpty()) { + return new FilePatch(type, path, List.of(), 0, 0); + } + if (type == PatchType.ADD && body.stream().noneMatch(line -> line.startsWith("@@"))) { + List lines = new ArrayList<>(); + for (String line : body) { + if (!line.startsWith("+")) { + throw patchInvalid("Added file lines must start with '+'."); + } + lines.add(new DiffLine('+', line.substring(1))); + } + return new FilePatch(type, path, List.of(new Hunk(1, lines)), lines.size(), 0); + } + List hunks = new ArrayList<>(); + List current = null; + Integer oldStart = null; + int added = 0; + int deleted = 0; + for (String line : body) { + Matcher header = HUNK_HEADER.matcher(line); + if (header.matches()) { + if (current != null) { + hunks.add(new Hunk(oldStart, List.copyOf(current))); + } + current = new ArrayList<>(); + oldStart = header.group(1) == null ? null : Integer.parseInt(header.group(1)); + continue; + } + if ("\\ No newline at end of file".equals(line)) { + continue; + } + if (current == null) { + throw patchInvalid("Patch hunk is missing an @@ header."); + } + if (line.isEmpty() || (line.charAt(0) != ' ' && line.charAt(0) != '+' && line.charAt(0) != '-')) { + throw patchInvalid("Invalid patch hunk line."); + } + char kind = line.charAt(0); + current.add(new DiffLine(kind, line.substring(1))); + if (kind == '+') { + added++; + } else if (kind == '-') { + deleted++; + } + } + if (current != null) { + hunks.add(new Hunk(oldStart, List.copyOf(current))); + } + if (hunks.isEmpty() && type != PatchType.DELETE) { + throw patchInvalid("Patch file section does not contain a hunk."); + } + return new FilePatch(type, path, List.copyOf(hunks), added, deleted); + } + + private static int locateUnique(List content, List oldLines, Integer declaredStart) { + if (oldLines.isEmpty()) { + if (declaredStart == null) { + if (content.isEmpty()) { + return 0; + } + throw new WorkspaceToolException("PATCH_CONFLICT", + "Insertion hunk needs a line position or context.", false); + } + int position = Math.max(0, declaredStart - 1); + if (position > content.size()) { + throw new WorkspaceToolException("PATCH_CONFLICT", + "Insertion position is outside the target file.", false); + } + return position; + } + int match = -1; + for (int start = 0; start + oldLines.size() <= content.size(); start++) { + boolean equal = true; + for (int offset = 0; offset < oldLines.size(); offset++) { + if (!content.get(start + offset).equals(oldLines.get(offset))) { + equal = false; + break; + } + } + if (equal) { + if (match >= 0) { + throw new WorkspaceToolException("PATCH_CONFLICT", + "Patch hunk context is not unique.", false); + } + match = start; + } + } + if (match < 0) { + throw new WorkspaceToolException("PATCH_CONFLICT", + "Patch hunk context was not found.", false); + } + return match; + } + + private static List splitDocument(String content) { + if (content.isEmpty()) { + return new ArrayList<>(); + } + String normalized = content.replace("\r\n", "\n").replace('\r', '\n'); + String[] values = normalized.split("\n", -1); + int length = values.length; + if (length > 0 && values[length - 1].isEmpty()) { + length--; + } + List lines = new ArrayList<>(length); + for (int index = 0; index < length; index++) { + lines.add(values[index]); + } + return lines; + } + + private static String headerPath(String header) { + String trimmed = header.trim(); + int tab = trimmed.indexOf('\t'); + return tab < 0 ? trimmed : trimmed.substring(0, tab); + } + + private static String stripPrefix(String path) { + if (path.startsWith("a/") || path.startsWith("b/")) { + return path.substring(2); + } + return path; + } + + private static WorkspaceToolException patchInvalid(String message) { + return new WorkspaceToolException("PATCH_INVALID", message, false); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspacePathGuard.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspacePathGuard.java new file mode 100644 index 0000000..691ee32 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspacePathGuard.java @@ -0,0 +1,311 @@ +package com.easyagents.agent.runtime.tool.operate; + +import com.easyagents.agent.runtime.AgentRuntimeException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.regex.Pattern; + +/** + * 工作区路径安全边界。 + * + *

调用方只能提交工作区相对路径。该类拒绝路径穿越、宿主绝对路径、符号链接、设备文件和 + * 其他非普通文件目标,并只向上层返回相对展示路径。 + */ +public final class WorkspacePathGuard { + + private static final Pattern WINDOWS_ABSOLUTE_PATH = Pattern.compile("^[A-Za-z]:[\\\\/].*"); + private final Path workspaceRoot; + + /** + * 创建路径保护器并确保工作区根目录存在。 + * + * @param workspaceRoot 受信任的工作区绝对目录 + * @throws AgentRuntimeException 根目录无效或无法创建时抛出 + */ + public WorkspacePathGuard(Path workspaceRoot) { + if (workspaceRoot == null || !workspaceRoot.isAbsolute()) { + throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID", + "Workspace root must be an absolute path.", false); + } + try { + Files.createDirectories(workspaceRoot.normalize()); + this.workspaceRoot = workspaceRoot.normalize().toRealPath(); + if (!Files.isDirectory(this.workspaceRoot, LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID", + "Workspace root is not a directory.", false); + } + } catch (IOException error) { + throw new WorkspaceToolException("WORKSPACE_CONFIG_INVALID", + "Workspace root cannot be initialized.", false, error); + } + } + + /** + * 获取仅供受信任 Runtime 内部使用的真实工作区根目录。 + * + * @return 真实工作区根目录 + */ + Path root() { + return workspaceRoot; + } + + /** + * 解析已存在的普通文件。 + * + * @param relativePath 模型提交的工作区相对路径 + * @return 受控普通文件路径 + * @throws AgentRuntimeException 路径不安全、目标不存在或不是普通文件时抛出 + */ + public Path resolveExistingFile(String relativePath) { + Path target = resolve(relativePath, false); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("FILE_NOT_FOUND", "Workspace file does not exist.", false); + } + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("FILE_TYPE_INVALID", + "Workspace target is not a regular file.", false); + } + rejectHardLink(target); + return target; + } + + /** + * 解析已存在的普通文件或目录,用于受控命令参数预检。 + * + * @param relativePath 模型提交的工作区相对路径 + * @return 受控现有条目 + * @throws AgentRuntimeException 目标不安全、不存在或属于特殊文件时抛出 + */ + public Path resolveExistingEntry(String relativePath) { + Path target = resolve(relativePath, true); + if (Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + rejectHardLink(target); + return target; + } + if (Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) { + return target; + } + throw new WorkspaceToolException("FILE_TYPE_INVALID", + "Workspace target is not a regular file or directory.", false); + } + + /** + * 解析命令声明的工作区路径,允许尚不存在的创建目标和已存在的普通文件或目录。 + * + * @param relativePath 命令路径参数 + * @return 受控工作区路径 + * @throws AgentRuntimeException 路径越界、包含链接或属于特殊文件时抛出 + */ + Path resolveCommandPath(String relativePath) { + Path target = resolve(relativePath, true); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + return target; + } + if (Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + rejectHardLink(target); + return target; + } + if (Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) { + return target; + } + throw new WorkspaceToolException("FILE_TYPE_INVALID", + "Shell target is not a regular file or directory.", false); + } + + /** + * 解析已存在的目录。 + * + * @param relativePath 模型提交的工作区相对路径,`.` 表示工作区根 + * @return 受控目录路径 + * @throws AgentRuntimeException 路径不安全、目标不存在或不是目录时抛出 + */ + public Path resolveExistingDirectory(String relativePath) { + Path target = resolve(relativePath, true); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("FILE_NOT_FOUND", "Workspace directory does not exist.", false); + } + if (!Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("FILE_TYPE_INVALID", + "Workspace target is not a directory.", false); + } + return target; + } + + /** + * 解析可写入的文件路径,允许目标和父目录尚未创建。 + * + * @param relativePath 模型提交的工作区相对路径 + * @return 受控文件路径 + * @throws AgentRuntimeException 路径不安全或现有目标不是普通文件时抛出 + */ + public Path resolveForWrite(String relativePath) { + Path target = resolve(relativePath, false); + if (target.equals(workspaceRoot)) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", + "Workspace root cannot be used as a file target.", false); + } + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) + && !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("FILE_TYPE_INVALID", + "Workspace target is not a regular file.", false); + } + if (Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + rejectHardLink(target); + } + return target; + } + + /** + * 安全创建目标文件的父目录。 + * + * @param target 已由本保护器解析的目标路径 + * @throws AgentRuntimeException 父目录创建失败或出现符号链接时抛出 + */ + public void ensureParentDirectories(Path target) { + requireInsideWorkspace(target); + Path parent = target.getParent(); + if (parent == null || parent.equals(workspaceRoot)) { + return; + } + Path relative = workspaceRoot.relativize(parent); + Path current = workspaceRoot; + try { + for (Path segment : relative) { + current = current.resolve(segment); + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + rejectSymbolicLink(current); + if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("FILE_TYPE_INVALID", + "Workspace parent is not a directory.", false); + } + continue; + } + Files.createDirectory(current); + rejectSymbolicLink(current); + } + } catch (IOException error) { + throw new WorkspaceToolException("WORKSPACE_IO_FAILED", + "Workspace parent directory cannot be created.", true, error); + } + } + + /** + * 再次校验目标路径的现有链路不包含符号链接,供原子提交前缩短竞态窗口。 + * + * @param target 已解析目标 + * @throws AgentRuntimeException 路径越界或包含符号链接时抛出 + */ + public void revalidate(Path target) { + requireInsideWorkspace(target); + rejectExistingSymbolicLinks(target); + if (Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + rejectHardLink(target); + } + } + + /** + * 将内部路径转换为不泄露宿主目录的工作区相对展示路径。 + * + * @param target 工作区内路径 + * @return 使用正斜杠的相对路径,根目录返回 `.` + */ + public String display(Path target) { + requireInsideWorkspace(target); + Path relative = workspaceRoot.relativize(target.normalize()); + if (relative.toString().isEmpty()) { + return "."; + } + return relative.toString().replace(target.getFileSystem().getSeparator(), "/"); + } + + private Path resolve(String relativePath, boolean allowRoot) { + validateRelativeInput(relativePath, allowRoot); + Path submitted; + try { + submitted = Path.of(relativePath); + } catch (RuntimeException error) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", "Invalid workspace path.", false, error); + } + Path target = workspaceRoot.resolve(submitted).normalize(); + requireInsideWorkspace(target); + rejectExistingSymbolicLinks(target); + return target; + } + + private void validateRelativeInput(String relativePath, boolean allowRoot) { + if (relativePath == null || relativePath.isBlank() || relativePath.indexOf('\0') >= 0) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", + "Workspace path is required and must not contain NUL.", false); + } + String trimmed = relativePath.trim(); + if (trimmed.startsWith("~") || WINDOWS_ABSOLUTE_PATH.matcher(trimmed).matches()) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", + "Only workspace-relative paths are allowed.", false); + } + Path submitted; + try { + submitted = Path.of(trimmed); + } catch (RuntimeException error) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", "Invalid workspace path.", false, error); + } + if (submitted.isAbsolute()) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", + "Only workspace-relative paths are allowed.", false); + } + for (Path segment : submitted) { + if ("..".equals(segment.toString())) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", + "Workspace path traversal is not allowed.", false); + } + } + if (!allowRoot && (".".equals(trimmed) || submitted.getNameCount() == 0)) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", + "Workspace root cannot be used as a file target.", false); + } + } + + private void rejectExistingSymbolicLinks(Path target) { + Path relative = workspaceRoot.relativize(target); + Path current = workspaceRoot; + for (Path segment : relative) { + current = current.resolve(segment); + if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + break; + } + rejectSymbolicLink(current); + } + } + + private void rejectSymbolicLink(Path path) { + if (Files.isSymbolicLink(path)) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", + "Symbolic links are not allowed in workspace paths.", false); + } + } + + private void rejectHardLink(Path path) { + try { + Object value = Files.getAttribute(path, "unix:nlink", LinkOption.NOFOLLOW_LINKS); + if (value instanceof Number number && number.longValue() > 1) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", + "Hard-linked files are not allowed in workspace paths.", false); + } + } catch (UnsupportedOperationException ignored) { + // 非 Unix 文件系统没有 unix:nlink 属性,仍保留 NOFOLLOW 与普通文件类型校验。 + } catch (IOException error) { + throw new WorkspaceToolException("WORKSPACE_IO_FAILED", + "Workspace file link count cannot be inspected.", true, error); + } + } + + private void requireInsideWorkspace(Path target) { + if (target == null || !target.normalize().startsWith(workspaceRoot)) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", + "Workspace path escapes the configured root.", false); + } + } + +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaGuard.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaGuard.java new file mode 100644 index 0000000..545f9ed --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaGuard.java @@ -0,0 +1,263 @@ +package com.easyagents.agent.runtime.tool.operate; + +import com.easyagents.agent.runtime.AgentRuntimeException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Map; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Stream; + +/** + * 工作区容量与文件数量校验器。 + */ +final class WorkspaceQuotaGuard { + + private static final long MAX_SCANNED_ENTRIES = 100_000L; + private static final int DEFAULT_ARCHIVE_ENTRY_LIMIT = 10_000; + private static final long DEFAULT_ARCHIVE_TOTAL_LIMIT = 512L * 1024L * 1024L; + private static final long DEFAULT_ARCHIVE_FILE_LIMIT = 64L * 1024L * 1024L; + + private final WorkspacePathGuard pathGuard; + private final WorkspaceQuotaLimits limits; + private final WorkspaceQuotaHook hook; + + /** + * 创建配额校验器。 + * + * @param pathGuard 路径保护器 + * @param limits 配额限制 + * @param hook 业务侧附加校验 Hook + */ + WorkspaceQuotaGuard(WorkspacePathGuard pathGuard, + WorkspaceQuotaLimits limits, + WorkspaceQuotaHook hook) { + this.pathGuard = pathGuard; + this.limits = limits == null ? WorkspaceQuotaLimits.unlimited() : limits; + this.hook = hook == null ? WorkspaceQuotaHook.noop() : hook; + } + + /** + * 校验文件是否允许被完整读取。 + * + * @param target 目标普通文件 + */ + void validateFullRead(Path target) { + try { + long size = Files.size(target); + if (limits.getMaxReadSize() > 0 && size > limits.getMaxReadSize()) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace full-file read exceeds max-read-size.", false); + } + hook.beforeRead(pathGuard.root(), target, size); + } catch (IOException error) { + throw new WorkspaceToolException("WORKSPACE_IO_FAILED", + "Workspace file size cannot be inspected.", true, error); + } + } + + /** + * 记录一次范围读取并调用业务侧配额 Hook。 + * + * @param target 目标文件 + * @param readBytes 实际返回字节数 + */ + void validateRangeRead(Path target, long readBytes) { + if (limits.getMaxReadSize() > 0 && readBytes > limits.getMaxReadSize()) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace range read exceeds max-read-size.", false); + } + hook.beforeRead(pathGuard.root(), target, readBytes); + } + + /** + * 获取范围读取字节上限。 + * + * @return 字节上限,零表示使用 Runtime 固定安全上限 + */ + long maxReadSize() { + return limits.getMaxReadSize() > 0 ? limits.getMaxReadSize() : 2L * 1024L * 1024L; + } + + /** + * 获取单层目录最大返回条目数。 + * + * @return 最大条目数 + */ + int maxDirectoryEntries() { + long configured = limits.getMaxFileCount(); + return configured > 0 ? (int) Math.min(configured, 1000) : 1000; + } + + /** + * 获取安全归档单次最大条目数。 + * + * @return 条目数上限 + */ + int maxArchiveEntries() { + long configured = limits.getMaxFileCount(); + return configured > 0 + ? (int) Math.min(configured, DEFAULT_ARCHIVE_ENTRY_LIMIT) + : DEFAULT_ARCHIVE_ENTRY_LIMIT; + } + + /** + * 获取安全归档展开总量上限。 + * + * @return 展开总字节数上限 + */ + long maxArchiveTotalSize() { + long configured = limits.getMaxTotalSize(); + return configured > 0 ? Math.min(configured, DEFAULT_ARCHIVE_TOTAL_LIMIT) : DEFAULT_ARCHIVE_TOTAL_LIMIT; + } + + /** + * 获取安全归档单文件上限。 + * + * @return 单文件字节数上限 + */ + long maxArchiveSingleFileSize() { + long configured = limits.getMaxSingleFileSize(); + return configured > 0 ? Math.min(configured, DEFAULT_ARCHIVE_FILE_LIMIT) : DEFAULT_ARCHIVE_FILE_LIMIT; + } + + /** + * 校验单个文件变更后的工作区配额。 + * + * @param target 目标文件 + * @param resultingBytes 变更后的文件字节数,删除时为零 + */ + void validateWrite(Path target, long resultingBytes) { + validateBatch(Map.of(target, resultingBytes)); + } + + /** + * 校验一批文件变更后的工作区配额。 + * + * @param resultingSizes 目标路径到变更后字节数的映射,负数表示删除 + */ + void validateBatch(Map resultingSizes) { + if (resultingSizes == null || resultingSizes.isEmpty()) { + return; + } + WorkspaceUsage usage = scanUsage(); + long projectedSize = usage.totalSize(); + long projectedCount = usage.entryCount(); + Set plannedEntries = new HashSet<>(); + for (Map.Entry entry : resultingSizes.entrySet()) { + Path target = entry.getKey(); + long resultingBytes = entry.getValue() == null ? 0 : entry.getValue(); + boolean exists = Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS); + long previousBytes = sizeIfRegular(target); + projectedSize -= previousBytes; + if (resultingBytes < 0) { + if (exists) { + projectedCount--; + } + hook.beforeWrite(pathGuard.root(), target, previousBytes, 0); + continue; + } + if (limits.getMaxSingleFileSize() > 0 && resultingBytes > limits.getMaxSingleFileSize()) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace file exceeds max-single-file-size.", false); + } + try { + projectedSize = Math.addExact(projectedSize, resultingBytes); + } catch (ArithmeticException error) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace exceeds max-total-size.", false, error); + } + if (!exists) { + if (plannedEntries.add(target)) { + projectedCount++; + } + Path parent = target.getParent(); + while (parent != null && !parent.equals(pathGuard.root())) { + if (!Files.exists(parent, LinkOption.NOFOLLOW_LINKS) && plannedEntries.add(parent)) { + projectedCount++; + } + parent = parent.getParent(); + } + } + hook.beforeWrite(pathGuard.root(), target, previousBytes, resultingBytes); + } + if (limits.getMaxTotalSize() > 0 && projectedSize > limits.getMaxTotalSize()) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace exceeds max-total-size.", false); + } + if (limits.getMaxFileCount() > 0 && projectedCount > limits.getMaxFileCount()) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace exceeds max-file-count.", false); + } + } + + /** + * 校验当前工作区已处于配额范围内。 + */ + void validateCurrentUsage() { + WorkspaceUsage usage = scanUsage(); + if (limits.getMaxTotalSize() > 0 && usage.totalSize() > limits.getMaxTotalSize()) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace exceeds max-total-size.", false); + } + if (limits.getMaxFileCount() > 0 && usage.entryCount() > limits.getMaxFileCount()) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace exceeds max-file-count.", false); + } + } + + private WorkspaceUsage scanUsage() { + long totalSize = 0; + long entryCount = 0; + try (Stream paths = Files.walk(pathGuard.root())) { + for (Path path : (Iterable) paths::iterator) { + if (path.equals(pathGuard.root())) { + continue; + } + entryCount++; + if (entryCount > MAX_SCANNED_ENTRIES) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace contains too many entries to inspect safely.", false); + } + if (Files.isSymbolicLink(path)) { + throw new WorkspaceToolException("WORKSPACE_PATH_INVALID", + "Workspace contains a symbolic link.", false); + } + if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + totalSize = Math.addExact(totalSize, Files.size(path)); + } else if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new WorkspaceToolException("FILE_TYPE_INVALID", + "Workspace contains a non-regular entry.", false); + } + } + return new WorkspaceUsage(totalSize, entryCount); + } catch (IOException | ArithmeticException error) { + throw new WorkspaceToolException("WORKSPACE_IO_FAILED", + "Workspace usage cannot be inspected.", true, error); + } + } + + private long sizeIfRegular(Path target) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + return 0; + } + try { + return Files.size(target); + } catch (IOException error) { + throw new WorkspaceToolException("WORKSPACE_IO_FAILED", + "Workspace file size cannot be inspected.", true, error); + } + } + + /** + * 工作区当前使用量。 + * + * @param totalSize 普通文件总字节数 + * @param entryCount 文件与目录条目数量,不包含工作区根 + */ + private record WorkspaceUsage(long totalSize, long entryCount) { + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaHook.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaHook.java new file mode 100644 index 0000000..759d9a0 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaHook.java @@ -0,0 +1,74 @@ +package com.easyagents.agent.runtime.tool.operate; + +import java.nio.file.Path; + +/** + * 业务侧可选的工作区配额校验 Hook。 + * + *

Runtime 会先执行内置容量校验,再调用该 Hook。参数中的路径仅供受信任的服务端实现使用, + * 不会进入 Tool Schema、metadata 或模型结果。 + */ +public interface WorkspaceQuotaHook { + + /** + * 在读取普通文件前执行附加校验。 + * + * @param workspaceRoot 工作区根目录 + * @param target 目标普通文件 + * @param requestedBytes 预计读取字节数 + */ + void beforeRead(Path workspaceRoot, Path target, long requestedBytes); + + /** + * 在提交文件变更前执行附加校验。 + * + * @param workspaceRoot 工作区根目录 + * @param target 目标文件 + * @param previousBytes 原文件字节数,不存在时为零 + * @param resultingBytes 新文件字节数,删除时为零 + */ + void beforeWrite(Path workspaceRoot, Path target, long previousBytes, long resultingBytes); + + /** + * 获取无操作 Hook。 + * + * @return 无操作 Hook + */ + static WorkspaceQuotaHook noop() { + return NoopWorkspaceQuotaHook.INSTANCE; + } + + /** + * 无操作 Hook 实现。 + */ + final class NoopWorkspaceQuotaHook implements WorkspaceQuotaHook { + + private static final NoopWorkspaceQuotaHook INSTANCE = new NoopWorkspaceQuotaHook(); + + private NoopWorkspaceQuotaHook() { + } + + /** + * 不执行附加读取校验。 + * + * @param workspaceRoot 工作区根目录 + * @param target 目标普通文件 + * @param requestedBytes 预计读取字节数 + */ + @Override + public void beforeRead(Path workspaceRoot, Path target, long requestedBytes) { + } + + /** + * 不执行附加写入校验。 + * + * @param workspaceRoot 工作区根目录 + * @param target 目标文件 + * @param previousBytes 原文件字节数 + * @param resultingBytes 新文件字节数 + */ + @Override + public void beforeWrite(Path workspaceRoot, Path target, long previousBytes, long resultingBytes) { + } + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaLimits.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaLimits.java new file mode 100644 index 0000000..762d4fb --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceQuotaLimits.java @@ -0,0 +1,78 @@ +package com.easyagents.agent.runtime.tool.operate; + +/** + * 工作区资源配额。 + * + *

所有大小均以字节计。小于等于零的值表示对应维度不限制,便于通用 Runtime 保持兼容, + * 生产系统应由业务侧显式传入有界配置。 + */ +public final class WorkspaceQuotaLimits { + + private final long maxTotalSize; + private final long maxSingleFileSize; + private final long maxFileCount; + private final long maxReadSize; + + /** + * 创建工作区配额。 + * + * @param maxTotalSize 工作区普通文件总字节数 + * @param maxSingleFileSize 单个普通文件最大字节数 + * @param maxFileCount 工作区文件与目录条目最大数量,不包含工作区根 + * @param maxReadSize 单次读取文件最大字节数 + */ + public WorkspaceQuotaLimits(long maxTotalSize, + long maxSingleFileSize, + long maxFileCount, + long maxReadSize) { + this.maxTotalSize = maxTotalSize; + this.maxSingleFileSize = maxSingleFileSize; + this.maxFileCount = maxFileCount; + this.maxReadSize = maxReadSize; + } + + /** + * 创建无限制配额。 + * + * @return 无限制配额 + */ + public static WorkspaceQuotaLimits unlimited() { + return new WorkspaceQuotaLimits(0, 0, 0, 0); + } + + /** + * 获取工作区总量上限。 + * + * @return 总字节数上限 + */ + public long getMaxTotalSize() { + return maxTotalSize; + } + + /** + * 获取单文件上限。 + * + * @return 单文件字节数上限 + */ + public long getMaxSingleFileSize() { + return maxSingleFileSize; + } + + /** + * 获取工作区条目数量上限。 + * + * @return 文件与目录条目数量上限 + */ + public long getMaxFileCount() { + return maxFileCount; + } + + /** + * 获取单次读取上限。 + * + * @return 读取字节数上限 + */ + public long getMaxReadSize() { + return maxReadSize; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceTextFiles.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceTextFiles.java new file mode 100644 index 0000000..2064b7f --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceTextFiles.java @@ -0,0 +1,269 @@ +package com.easyagents.agent.runtime.tool.operate; + +import java.io.IOException; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.LinkOption; +import java.nio.file.OpenOption; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Set; + +/** + * 工作区 UTF-8 文本文件原子读写辅助方法。 + */ +final class WorkspaceTextFiles { + + private WorkspaceTextFiles() { + } + + /** + * 严格按 UTF-8 读取文件。 + * + * @param target 目标普通文件 + * @return 文件文本 + */ + static String readUtf8(Path target) { + Set options = Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS); + try (SeekableByteChannel channel = Files.newByteChannel(target, options); + java.io.InputStream input = Channels.newInputStream(channel)) { + byte[] bytes = input.readAllBytes(); + return decodeUtf8(bytes); + } catch (IOException error) { + throw new WorkspaceToolException("WORKSPACE_IO_FAILED", + "Workspace text file cannot be read.", true, error); + } + } + + /** + * 以流式方式读取有界行范围,避免为了返回少量行先加载完整文本。 + * + * @param target 目标普通文件 + * @param ranges 可选行范围,支持 `start,end` 与负数尾部索引 + * @return 带真实起始行号的行范围 + */ + static RangedLines readUtf8Lines(Path target, String ranges, long maxReadBytes) { + ParsedRange range = ParsedRange.parse(ranges); + Set options = Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS); + try (SeekableByteChannel channel = Files.newByteChannel(target, options); + BufferedReader reader = new BufferedReader(new InputStreamReader( + Channels.newInputStream(channel), StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT)))) { + if (range.negative()) { + return readTail(reader, range, maxReadBytes); + } + List selected = new ArrayList<>(); + long selectedBytes = 0; + int lineNumber = 0; + String line; + while ((line = reader.readLine()) != null) { + lineNumber++; + if (lineNumber >= range.start() && lineNumber <= range.end()) { + selectedBytes = addLineBytes(selectedBytes, line, maxReadBytes); + selected.add(line); + } + if (lineNumber >= range.end()) { + break; + } + } + if (lineNumber < range.start() && lineNumber > 0) { + throw invalidRange("Invalid range: start line is outside the file."); + } + return new RangedLines(range.start(), selected, selectedBytes); + } catch (CharacterCodingException error) { + throw new WorkspaceToolException("FILE_ENCODING_INVALID", + "Workspace file is not valid UTF-8 text.", false, error); + } catch (IOException error) { + throw new WorkspaceToolException("WORKSPACE_IO_FAILED", + "Workspace text file cannot be read.", true, error); + } + } + + /** + * 严格解码 UTF-8 字节。 + * + * @param bytes 文本字节 + * @return UTF-8 文本 + */ + static String decodeUtf8(byte[] bytes) { + try { + CharBuffer decoded = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)); + return decoded.toString(); + } catch (CharacterCodingException error) { + throw new WorkspaceToolException("FILE_ENCODING_INVALID", + "Workspace file is not valid UTF-8 text.", false, error); + } + } + + /** + * 使用同目录临时文件原子替换目标内容。 + * + * @param pathGuard 路径保护器 + * @param target 目标文件 + * @param bytes 新文件字节 + */ + static void atomicWrite(WorkspacePathGuard pathGuard, Path target, byte[] bytes) { + pathGuard.ensureParentDirectories(target); + Path parent = target.getParent(); + Path temporary = null; + try { + temporary = Files.createTempFile(parent, ".easyagents-write-", ".tmp"); + try (FileChannel channel = FileChannel.open( + temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + pathGuard.revalidate(target); + Files.move(temporary, target, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + forceDirectory(parent); + } catch (IOException error) { + throw new WorkspaceToolException("WORKSPACE_IO_FAILED", + "Workspace text file cannot be committed.", true, error); + } finally { + if (temporary != null) { + try { + Files.deleteIfExists(temporary); + } catch (IOException ignored) { + // 提交失败已经向上抛出,临时文件清理失败由后续工作区清理任务兜底。 + } + } + } + } + + private static RangedLines readTail(BufferedReader reader, + ParsedRange range, + long maxReadBytes) throws IOException { + long requestedKeep = Math.max(Math.abs((long) range.start()), Math.abs((long) range.end())); + if (requestedKeep > Math.min(maxReadBytes, 100_000L)) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Requested tail range exceeds the configured read bound.", false); + } + int keep = Math.toIntExact(requestedKeep); + Deque tail = new ArrayDeque<>(keep); + int lineCount = 0; + String line; + while ((line = reader.readLine()) != null) { + lineCount++; + if (tail.size() == keep) { + tail.removeFirst(); + } + long lineBytes = line.getBytes(StandardCharsets.UTF_8).length + 1L; + if (lineBytes > maxReadBytes) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace range read exceeds max-read-size.", false); + } + tail.addLast(line); + } + if (lineCount == 0) { + return new RangedLines(1, List.of(), 0); + } + int start = Math.max(1, lineCount + range.start() + 1); + int end = Math.min(lineCount, lineCount + range.end() + 1); + if (start > end) { + throw invalidRange("Invalid range: start line is greater than end line."); + } + int retainedStart = lineCount - tail.size() + 1; + List retained = new ArrayList<>(tail); + List selected = new ArrayList<>( + retained.subList(start - retainedStart, end - retainedStart + 1)); + long selectedBytes = 0; + for (String selectedLine : selected) { + selectedBytes = addLineBytes(selectedBytes, selectedLine, maxReadBytes); + } + return new RangedLines(start, selected, selectedBytes); + } + + private static long addLineBytes(long current, String line, long maxReadBytes) { + long updated; + try { + updated = Math.addExact(current, line.getBytes(StandardCharsets.UTF_8).length + 1L); + } catch (ArithmeticException error) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace range read exceeds max-read-size.", false, error); + } + if (updated > maxReadBytes) { + throw new WorkspaceToolException("WORKSPACE_QUOTA_EXCEEDED", + "Workspace range read exceeds max-read-size.", false); + } + return updated; + } + + private static void forceDirectory(Path directory) { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (IOException | UnsupportedOperationException ignored) { + // 某些文件系统不支持目录 fsync;文件内容和原子 rename 已经完成。 + } + } + + private static WorkspaceToolException invalidRange(String message) { + return new WorkspaceToolException("INVALID_ARGUMENT", message, false); + } + + /** + * 流式读取结果。 + * + * @param startLine 第一行真实 1-based 行号 + * @param lines 文本行 + * @param readBytes 返回文本字节数 + */ + record RangedLines(int startLine, List lines, long readBytes) { + } + + /** + * 归一化行范围。 + * + * @param start 起始行,允许负数 + * @param end 结束行,允许负数 + * @param negative 是否为尾部范围 + */ + private record ParsedRange(int start, int end, boolean negative) { + + private static ParsedRange parse(String ranges) { + if (ranges == null || ranges.isBlank()) { + return new ParsedRange(1, Integer.MAX_VALUE, false); + } + String normalized = ranges.trim().replace("[", "").replace("]", ""); + String[] parts = normalized.split(",", -1); + if (parts.length != 2) { + throw invalidRange("Invalid range format. Expected 'start,end'."); + } + try { + int start = Integer.parseInt(parts[0].trim()); + int end = Integer.parseInt(parts[1].trim()); + if (start == 0 || end == 0 || (start < 0) != (end < 0)) { + throw invalidRange("Invalid range: use either positive or negative line numbers."); + } + if (start > end) { + throw invalidRange("Invalid range: start line is greater than end line."); + } + return new ParsedRange(start, end, start < 0); + } catch (NumberFormatException error) { + throw new WorkspaceToolException("INVALID_ARGUMENT", + "Invalid range format. Expected integer line numbers.", false, error); + } + } + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceToolException.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceToolException.java new file mode 100644 index 0000000..2f9d935 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceToolException.java @@ -0,0 +1,57 @@ +package com.easyagents.agent.runtime.tool.operate; + +import com.easyagents.agent.runtime.AgentRuntimeException; + +/** + * 带稳定工具错误码和重试语义的工作区异常。 + */ +final class WorkspaceToolException extends AgentRuntimeException { + + private final String code; + private final boolean retryable; + + /** + * 创建工具异常。 + * + * @param code 稳定错误码 + * @param message 可安全返回给模型的信息 + * @param retryable 是否可重试 + */ + WorkspaceToolException(String code, String message, boolean retryable) { + super(message); + this.code = code; + this.retryable = retryable; + } + + /** + * 创建带内部原因的工具异常。 + * + * @param code 稳定错误码 + * @param message 可安全返回给模型的信息 + * @param retryable 是否可重试 + * @param cause 仅写入服务端日志的内部原因 + */ + WorkspaceToolException(String code, String message, boolean retryable, Throwable cause) { + super(message, cause); + this.code = code; + this.retryable = retryable; + } + + /** + * 获取稳定错误码。 + * + * @return 错误码 + */ + String code() { + return code; + } + + /** + * 返回是否可重试。 + * + * @return 可重试时为 true + */ + boolean retryable() { + return retryable; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceToolResults.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceToolResults.java new file mode 100644 index 0000000..a3748e9 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/operate/WorkspaceToolResults.java @@ -0,0 +1,58 @@ +package com.easyagents.agent.runtime.tool.operate; + +import com.easyagents.agent.runtime.AgentRuntimeException; +import io.agentscope.core.message.ToolResultBlock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 内置工作区工具的稳定错误结果工厂。 + */ +final class WorkspaceToolResults { + + private static final Logger logger = LoggerFactory.getLogger(WorkspaceToolResults.class); + + private WorkspaceToolResults() { + } + + /** + * 将内部异常转换为不含宿主路径的稳定错误对象。 + * + * @param error 内部异常 + * @return Tool 错误结果 + */ + static ToolResultBlock error(AgentRuntimeException error) { + if (error instanceof WorkspaceToolException typed) { + if (typed.getCause() != null) { + logger.error("Workspace tool failed with code {}", typed.code(), typed); + } + return error(typed.code(), typed.getMessage(), typed.retryable()); + } + logger.error("Unexpected workspace tool failure", error); + return error("WORKSPACE_OPERATION_FAILED", "Workspace operation failed.", false); + } + + /** + * 创建稳定错误结果。 + * + * @param code 错误码 + * @param message 安全错误信息 + * @param retryable 是否可重试 + * @return Tool 错误结果 + */ + static ToolResultBlock error(String code, String message, boolean retryable) { + String json = "{\"code\":\"" + escape(code) + "\",\"message\":\"" + + escape(message) + "\",\"retryable\":" + retryable + "}"; + return ToolResultBlock.error(json); + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r"); + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java index 62fc4e8..1ee90f9 100644 --- a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java @@ -241,6 +241,7 @@ public class AgentScopeStatefulRuntimeTest { request.getAgentDefinition().setOperateToolSpecs(List.of( operateToolSpec(AgentOperateToolType.READ_FILE), operateToolSpec(AgentOperateToolType.WRITE_FILE), + operateToolSpec(AgentOperateToolType.PATCH), operateToolSpec(AgentOperateToolType.SHELL))); AgentScopeReActRuntime runtime = fakeRuntime(); @@ -251,6 +252,7 @@ public class AgentScopeStatefulRuntimeTest { Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.LIST_DIRECTORY_TOOL)); Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL)); Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL)); + Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.APPLY_PATCH_TOOL)); Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL)); } @@ -258,14 +260,13 @@ public class AgentScopeStatefulRuntimeTest { public void shouldSuspendShellOperateToolWithToolHitlInterceptor() { AgentInitRequest request = initRequest(); AgentOperateToolSpec shell = operateToolSpec(AgentOperateToolType.SHELL); - shell.setShellAllowedCommands(Set.of()); request.getAgentDefinition().setOperateToolSpecs(List.of(shell)); AgentScopeReActRuntime runtime = runtimeWithModel(List.of(ChatResponse.builder() .id("shell-call-message") .content(List.of(ToolUseBlock.builder() .id("call-shell") .name(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL) - .input(Map.of("command", "echo hello")) + .input(Map.of("command", "pwd")) .build())) .finishReason("tool_calls") .build())); @@ -280,6 +281,36 @@ public class AgentScopeStatefulRuntimeTest { Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)); } + @Test + public void shouldForceApprovalForRemoveWhenShellApprovalIsDisabled() { + AgentInitRequest request = initRequest(); + AgentOperateToolSpec shell = operateToolSpec(AgentOperateToolType.SHELL); + shell.setApprovalRequired(false); + request.getAgentDefinition().setOperateToolSpecs(List.of(shell)); + AgentScopeReActRuntime runtime = runtimeWithModel(List.of(ChatResponse.builder() + .id("forced-remove-message") + .content(List.of(ToolUseBlock.builder() + .id("call-remove") + .name(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL) + .input(Map.of("command", "'rm' removable.txt")) + .build())) + .finishReason("tool_calls") + .build())); + + runtime.init(request); + List events = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "remove file")) + .collectList() + .block(Duration.ofSeconds(5)); + + Assert.assertNotNull(events); + Assert.assertTrue(events.stream() + .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)); + Assert.assertTrue(events.stream() + .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)); + Assert.assertFalse(events.stream() + .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)); + } + @Test(expected = AgentRuntimeException.class) public void shouldRejectOperateToolNameConflictWithBusinessTool() { AgentInitRequest request = initRequest(); @@ -498,10 +529,12 @@ public class AgentScopeStatefulRuntimeTest { ToolUseBlock toolUse = ToolUseBlock.builder() .id("call-1") .name("search") - .input(Map.of("q", "easyflow")) + .input(Map.of("q", "sentinel-secret-input")) + .metadata(Map.of("authorization", "sentinel-secret-metadata")) .build(); ToolResultBlock toolResult = ToolResultBlock.of("call-1", "search", - TextBlock.builder().text("done").build(), Map.of("success", true)); + TextBlock.builder().text("sentinel-secret-result").build(), + Map.of("success", true, "token", "sentinel-secret-result-metadata")); observer.observe(new PreActingEvent(agent, toolkit, toolUse)).block(); observer.observe(new PostActingEvent(agent, toolkit, toolUse, toolResult)).block(); @@ -509,13 +542,16 @@ public class AgentScopeStatefulRuntimeTest { Assert.assertEquals(AgentRuntimeEventType.TOOL_CALL, events.get(0).getEventType()); Assert.assertEquals("RUNNING", events.get(0).getPayload().get("status")); - Assert.assertEquals("PRE_ACTING", events.get(0).getPayload().get("phase")); Assert.assertEquals("Search Tool", events.get(0).getPayload().get("toolDisplayName")); - Assert.assertEquals("search", events.get(0).getPayload().get("rawMcpToolName")); + Assert.assertFalse(events.get(0).getPayload().containsKey("input")); + Assert.assertFalse(events.get(0).getPayload().containsKey("content")); + Assert.assertFalse(events.get(0).getMetadata().toString().contains("sentinel-secret")); Assert.assertEquals(AgentRuntimeEventType.TOOL_RESULT, events.get(1).getEventType()); Assert.assertEquals("SUCCESS", events.get(1).getPayload().get("status")); - Assert.assertEquals("POST_ACTING", events.get(1).getPayload().get("phase")); Assert.assertEquals("Search Tool", events.get(1).getPayload().get("toolDisplayName")); + Assert.assertFalse(events.get(1).getPayload().containsKey("text")); + Assert.assertFalse(events.get(1).getMetadata().toString().contains("sentinel-secret")); + Assert.assertFalse(events.toString().contains("sentinel-secret")); } @Test @@ -748,7 +784,12 @@ public class AgentScopeStatefulRuntimeTest { @Test public void shouldRejectConcurrentStatefulStream() { - AgentScopeReActRuntime runtime = fakeRuntime(); + AgentScopeReActRuntime runtime = runtimeWithModel(List.of( + ChatResponse.builder() + .id("slow-response") + .content(List.of(TextBlock.builder().text("still running").build())) + .finishReason("stop") + .build()), Duration.ofSeconds(1)); runtime.init(initRequest()); reactor.core.Disposable disposable = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "first")) @@ -974,6 +1015,188 @@ public class AgentScopeStatefulRuntimeTest { Assert.assertTrue(sessionStore.exists("session-1")); } + /** + * 验证同一 Turn 内同一 MCP 的后续工具复用一次批准,新 Turn 会重新请求批准。 + */ + @Test + public void shouldReuseMcpApprovalWithinTurnAndResetForNextTurn() { + AgentInitRequest request = initRequest(); + AgentToolSpec resolveSpec = approvalRequiredMcpTool( + "mcp_101_resolve_library_id", "101"); + AgentToolSpec querySpec = approvalRequiredMcpTool( + "mcp_101_query_docs", "101"); + request.getAgentDefinition().setToolSpecs(List.of(resolveSpec, querySpec)); + AtomicInteger invocationCount = new AtomicInteger(); + request.setToolInvokers(Map.of( + resolveSpec.getName(), (arguments, context) -> { + invocationCount.incrementAndGet(); + return AgentToolResult.success("library-id"); + }, + querySpec.getName(), (arguments, context) -> { + invocationCount.incrementAndGet(); + return AgentToolResult.success("docs"); + })); + AgentScopeReActRuntime runtime = runtimeWithSequentialModel(List.of( + toolResponse("resolve-call", "call-resolve", resolveSpec.getName()), + toolResponse("query-call", "call-query", querySpec.getName()), + ChatResponse.builder() + .id("final-message") + .content(List.of(TextBlock.builder().text("done").build())) + .finishReason("stop") + .build(), + toolResponse("next-turn-call", "call-next", resolveSpec.getName()))); + runtime.init(request); + + List initialEvents = runtime.stream( + AgentMessage.text(AgentMessageRole.USER, "介绍 AG-UI")) + .collectList() + .block(); + AgentRuntimeEvent approval = initialEvents.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) + .findFirst() + .orElseThrow(); + + List resumeEvents = runtime.resume(resumeFromApproval(approval, true)) + .collectList() + .block(); + + Assert.assertEquals(2, invocationCount.get()); + Assert.assertFalse(resumeEvents.stream() + .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)); + Assert.assertTrue(resumeEvents.stream() + .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED)); + + List nextTurnEvents = runtime.stream( + AgentMessage.text(AgentMessageRole.USER, "再查一次")) + .collectList() + .block(); + + Assert.assertEquals(1, nextTurnEvents.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) + .count()); + Assert.assertTrue(nextTurnEvents.stream() + .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)); + } + + /** + * 验证同一推理消息中同一 MCP 的多个工具只生成一个审批请求。 + */ + @Test + public void shouldRequestOneApprovalForParallelToolsFromSameMcp() { + AgentInitRequest request = initRequest(); + AgentToolSpec resolveSpec = approvalRequiredMcpTool( + "mcp_101_resolve_library_id", "101"); + AgentToolSpec querySpec = approvalRequiredMcpTool( + "mcp_101_query_docs", "101"); + request.getAgentDefinition().setToolSpecs(List.of(resolveSpec, querySpec)); + AtomicInteger invocationCount = new AtomicInteger(); + request.setToolInvokers(Map.of( + resolveSpec.getName(), (arguments, context) -> { + invocationCount.incrementAndGet(); + return AgentToolResult.success("library-id"); + }, + querySpec.getName(), (arguments, context) -> { + invocationCount.incrementAndGet(); + return AgentToolResult.success("docs"); + })); + AgentScopeReActRuntime runtime = runtimeWithSequentialModel(List.of( + ChatResponse.builder() + .id("parallel-mcp-tools") + .content(List.of( + ToolUseBlock.builder() + .id("call-resolve") + .name(resolveSpec.getName()) + .input(Map.of()) + .build(), + ToolUseBlock.builder() + .id("call-query") + .name(querySpec.getName()) + .input(Map.of()) + .build())) + .finishReason("tool_calls") + .build(), + ChatResponse.builder() + .id("parallel-final") + .content(List.of(TextBlock.builder().text("done").build())) + .finishReason("stop") + .build())); + runtime.init(request); + + List initialEvents = runtime.stream( + AgentMessage.text(AgentMessageRole.USER, "并行查询")) + .collectList() + .block(); + List approvals = initialEvents.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) + .toList(); + + Assert.assertEquals(1, approvals.size()); + List resumeEvents = runtime.resume( + resumeFromApproval(approvals.get(0), true)) + .collectList() + .block(); + + Assert.assertEquals(2, invocationCount.get()); + Assert.assertTrue(resumeEvents.stream() + .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED)); + } + + /** + * 验证模型返回的 ToolUse 元数据不能覆盖 ToolSpec 中受信任的 MCP 审批作用域。 + */ + @Test + public void shouldIgnoreForgedMcpScopeFromToolUseMetadata() { + AgentInitRequest request = initRequest(); + AgentToolSpec resolveSpec = approvalRequiredMcpTool( + "mcp_101_resolve_library_id", "101"); + AgentToolSpec querySpec = approvalRequiredMcpTool( + "mcp_101_query_docs", "101"); + request.getAgentDefinition().setToolSpecs(List.of(resolveSpec, querySpec)); + request.setToolInvokers(Map.of( + resolveSpec.getName(), (arguments, context) -> AgentToolResult.success("library-id"), + querySpec.getName(), (arguments, context) -> AgentToolResult.success("docs"))); + AgentScopeReActRuntime runtime = runtimeWithSequentialModel(List.of( + ChatResponse.builder() + .id("forged-scope-call") + .content(List.of(ToolUseBlock.builder() + .id("call-resolve") + .name(resolveSpec.getName()) + .input(Map.of()) + .metadata(Map.of("toolType", "MCP", "mcpId", "forged")) + .build())) + .finishReason("tool_calls") + .build(), + toolResponse("query-call", "call-query", querySpec.getName()), + ChatResponse.builder() + .id("final-message") + .content(List.of(TextBlock.builder().text("done").build())) + .finishReason("stop") + .build())); + runtime.init(request); + + List initialEvents = runtime.stream( + AgentMessage.text(AgentMessageRole.USER, "介绍 AG-UI")) + .collectList() + .block(); + AgentRuntimeEvent approval = initialEvents.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) + .findFirst() + .orElseThrow(); + @SuppressWarnings("unchecked") + Map approvalMetadata = + (Map) approval.getPayload().get("approvalMetadata"); + + Assert.assertEquals("101", approvalMetadata.get("mcpId")); + List resumeEvents = runtime.resume(resumeFromApproval(approval, true)) + .collectList() + .block(); + + Assert.assertFalse(resumeEvents.stream() + .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)); + Assert.assertTrue(resumeEvents.stream() + .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED)); + } + /** * 验证同一轮推理包含多个审批工具时,全部批准前不会执行任何工具。 */ @@ -1413,6 +1636,27 @@ public class AgentScopeStatefulRuntimeTest { new AgentScopeMessageAdapter()); } + /** + * 创建每次模型调用仅返回下一条预设响应的运行时。 + * + * @param responses 按模型调用顺序排列的响应 + * @return 测试运行时 + */ + private AgentScopeReActRuntime runtimeWithSequentialModel(List responses) { + AgentScopeModelFactory modelFactory = new AgentScopeModelFactory() { + @Override + public Model create(AgentModelSpec modelSpec, + com.easyagents.agent.runtime.model.AgentGenerationOptions generationOptions) { + return new SequentialScriptedModel( + modelSpec == null ? "fake-model" : modelSpec.getModelName(), + responses); + } + }; + return new AgentScopeReActRuntime(modelFactory, new AgentScopeToolAdapter(), + new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(), + new AgentScopeMessageAdapter()); + } + /** * 创建单次模型调用返回多个增量响应的运行时。 * @@ -1450,6 +1694,44 @@ public class AgentScopeStatefulRuntimeTest { return request; } + /** + * 创建需要批准且归属于指定 MCP 的工具定义。 + * + * @param toolName 工具名称 + * @param mcpId MCP 标识 + * @return MCP 工具定义 + */ + private AgentToolSpec approvalRequiredMcpTool(String toolName, String mcpId) { + AgentToolSpec spec = new AgentToolSpec(); + spec.setName(toolName); + spec.setDescription(toolName); + spec.setApprovalRequired(true); + spec.getMetadata().put("toolType", "MCP"); + spec.getMetadata().put("mcpId", mcpId); + spec.getMetadata().put("mcpTitle", "Context7"); + return spec; + } + + /** + * 创建包含一次工具调用的模型响应。 + * + * @param messageId 响应消息标识 + * @param toolCallId 工具调用标识 + * @param toolName 工具名称 + * @return 模型响应 + */ + private ChatResponse toolResponse(String messageId, String toolCallId, String toolName) { + return ChatResponse.builder() + .id(messageId) + .content(List.of(ToolUseBlock.builder() + .id(toolCallId) + .name(toolName) + .input(Map.of()) + .build())) + .finishReason("tool_calls") + .build(); + } + private static class ScriptedModel implements Model { private final String modelName; @@ -1483,6 +1765,56 @@ public class AgentScopeStatefulRuntimeTest { } } + /** + * 每次调用按顺序返回一条响应的测试模型。 + */ + private static class SequentialScriptedModel implements Model { + + private final AtomicInteger invocationIndex = new AtomicInteger(); + private final String modelName; + private final List responses; + + /** + * 创建顺序响应模型。 + * + * @param modelName 模型名称 + * @param responses 按调用顺序排列的响应 + */ + private SequentialScriptedModel(String modelName, List responses) { + this.modelName = modelName; + this.responses = responses; + } + + /** + * 返回当前模型调用对应的单条响应。 + * + * @param messages 输入消息 + * @param toolSchemas 工具定义 + * @param options 生成配置 + * @return 单条响应流 + */ + @Override + public Flux stream(List messages, + List toolSchemas, + GenerateOptions options) { + int index = invocationIndex.getAndIncrement(); + if (index >= responses.size()) { + return Flux.error(new IllegalStateException("No scripted response for invocation " + index)); + } + return Flux.just(responses.get(index)); + } + + /** + * 返回模型名称。 + * + * @return 模型名称 + */ + @Override + public String getModelName() { + return modelName; + } + } + /** * 单次调用按顺序返回全部响应增量的测试模型。 */ diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolAdapterTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolAdapterTest.java index 0accff4..537d507 100644 --- a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolAdapterTest.java +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/AgentOperateToolAdapterTest.java @@ -8,6 +8,9 @@ import org.junit.Test; import java.util.List; import java.util.Set; +import java.nio.file.Files; +import java.nio.file.Path; +import java.io.IOException; /** * 测试 Agent 操作类工具适配器。 @@ -30,7 +33,7 @@ public class AgentOperateToolAdapterTest { } @Test - public void shouldRegisterWriteFileToolsWithDefaultHitlEnabled() { + public void shouldRegisterWriteFileToolsWithDefaultHitlDisabled() { Toolkit toolkit = new Toolkit(); AgentOperateToolSpec spec = spec(AgentOperateToolType.WRITE_FILE); @@ -39,20 +42,42 @@ public class AgentOperateToolAdapterTest { Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.WRITE_TEXT_FILE_TOOL)); Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.INSERT_TEXT_FILE_TOOL)); Assert.assertEquals(2, toolSpecs.size()); - Assert.assertTrue(toolSpecs.stream().allMatch(AgentToolSpec::isApprovalRequired)); + Assert.assertTrue(toolSpecs.stream().noneMatch(AgentToolSpec::isApprovalRequired)); + } + + @Test(expected = AgentRuntimeException.class) + public void shouldRejectEmptyShellWhitelist() { + AgentOperateToolSpec spec = spec(AgentOperateToolType.SHELL); + spec.setShellAllowedCommands(Set.of()); + + adapter.register(List.of(spec), new Toolkit()); } @Test - public void shouldRegisterShellToolWithEmptyWhitelist() { + public void shouldRegisterShellWithForcedRmApprovalMetadata() { Toolkit toolkit = new Toolkit(); AgentOperateToolSpec spec = spec(AgentOperateToolType.SHELL); - spec.setShellAllowedCommands(Set.of()); List toolSpecs = adapter.register(List.of(spec), toolkit); Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL)); Assert.assertEquals(1, toolSpecs.size()); Assert.assertTrue(toolSpecs.get(0).isApprovalRequired()); + Assert.assertNotNull(toolSpecs.get(0).getApprovalPolicy()); + Assert.assertEquals(List.of("rm"), toolSpecs.get(0).getMetadata().get("forceApprovalCommands")); + Assert.assertFalse(toolSpecs.get(0).getMetadata().containsKey("baseDir")); + } + + @Test + public void shouldRegisterPatchWithDefaultHitlDisabled() { + Toolkit toolkit = new Toolkit(); + AgentOperateToolSpec spec = spec(AgentOperateToolType.PATCH); + + List toolSpecs = adapter.register(List.of(spec), toolkit); + + Assert.assertNotNull(toolkit.getTool(AgentOperateToolAdapter.APPLY_PATCH_TOOL)); + Assert.assertEquals(1, toolSpecs.size()); + Assert.assertFalse(toolSpecs.get(0).isApprovalRequired()); } @Test @@ -95,7 +120,12 @@ public class AgentOperateToolAdapterTest { private AgentOperateToolSpec spec(AgentOperateToolType type) { AgentOperateToolSpec spec = new AgentOperateToolSpec(); spec.setType(type); - spec.setBaseDir(System.getProperty("java.io.tmpdir")); + try { + Path workspace = Files.createTempDirectory("operate-tool-adapter-"); + spec.setBaseDir(workspace.toAbsolutePath().toString()); + } catch (IOException error) { + throw new AssertionError(error); + } return spec; } } diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ApplyPatchToolTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ApplyPatchToolTest.java new file mode 100644 index 0000000..025e1cd --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ApplyPatchToolTest.java @@ -0,0 +1,142 @@ +package com.easyagents.agent.runtime.tool.operate; + +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.tool.ToolCallParam; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** + * 测试有界补丁工具。 + */ +public class ApplyPatchToolTest { + + @Test + public void shouldApplyMultiFileAddUpdateDeletePatch() throws IOException { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("existing.md"), "old\n"); + Files.writeString(fixture.root().resolve("remove.md"), "remove\n"); + String patch = """ + *** Begin Patch + *** Update File: existing.md + @@ + -old + +new + *** Add File: created.md + +created + *** Delete File: remove.md + *** End Patch"""; + + ToolResultBlock result = call(fixture.tool(), patch); + + Assert.assertTrue(text(result).contains("3 file(s)")); + Assert.assertEquals("new\n", Files.readString(fixture.root().resolve("existing.md"))); + Assert.assertEquals("created\n", Files.readString(fixture.root().resolve("created.md"))); + Assert.assertFalse(Files.exists(fixture.root().resolve("remove.md"))); + } + + @Test + public void shouldApplyStandardUnifiedDiff() throws IOException { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("standard.txt"), "before\n"); + String patch = """ + --- a/standard.txt + +++ b/standard.txt + @@ -1 +1 @@ + -before + +after + """; + + ToolResultBlock result = call(fixture.tool(), patch); + + Assert.assertTrue(text(result).contains("successfully")); + Assert.assertEquals("after\n", Files.readString(fixture.root().resolve("standard.txt"))); + } + + @Test + public void shouldRejectAmbiguousContextWithoutChangingFile() throws IOException { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("ambiguous.txt"), "same\nother\nsame\n"); + String patch = """ + *** Begin Patch + *** Update File: ambiguous.txt + @@ + -same + +changed + *** End Patch"""; + + ToolResultBlock result = call(fixture.tool(), patch); + + Assert.assertTrue(text(result).contains("PATCH_CONFLICT")); + Assert.assertEquals("same\nother\nsame\n", Files.readString(fixture.root().resolve("ambiguous.txt"))); + } + + @Test + public void shouldRejectPathEscapeBeforeChangingAnyFile() throws IOException { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("safe.txt"), "safe\n"); + String patch = """ + *** Begin Patch + *** Update File: safe.txt + @@ + -safe + +changed + *** Add File: ../escape.txt + +escape + *** End Patch"""; + + ToolResultBlock result = call(fixture.tool(), patch); + + Assert.assertTrue(text(result).contains("WORKSPACE_PATH_INVALID")); + Assert.assertEquals("safe\n", Files.readString(fixture.root().resolve("safe.txt"))); + } + + @Test + public void shouldRejectDeleteDiffThatDoesNotMatchCompleteFile() throws IOException { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("delete.txt"), "expected\nextra\n"); + String patch = """ + --- a/delete.txt + +++ /dev/null + @@ -1 +0,0 @@ + -expected + """; + + ToolResultBlock result = call(fixture.tool(), patch); + + Assert.assertTrue(text(result).contains("PATCH_CONFLICT")); + Assert.assertEquals("expected\nextra\n", Files.readString(fixture.root().resolve("delete.txt"))); + } + + private Fixture fixture() throws IOException { + Path root = Files.createTempDirectory("apply-patch-tool-").toAbsolutePath(); + WorkspacePathGuard pathGuard = new WorkspacePathGuard(root); + WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard( + pathGuard, + new WorkspaceQuotaLimits(1024 * 1024, 1024 * 1024, 100, 1024 * 1024), + WorkspaceQuotaHook.noop()); + return new Fixture(root, new ApplyPatchTool(pathGuard, quotaGuard, 1024 * 1024, 10, 1024 * 1024)); + } + + private ToolResultBlock call(ApplyPatchTool tool, String patch) { + return tool.callAsync(ToolCallParam.builder().input(Map.of("patch", patch)).build()).block(); + } + + private String text(ToolResultBlock result) { + return ((TextBlock) result.getOutput().get(0)).getText(); + } + + /** + * Patch 测试夹具。 + * + * @param root 工作区根 + * @param tool Patch 工具 + */ + private record Fixture(Path root, ApplyPatchTool tool) { + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ControlledShellToolTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ControlledShellToolTest.java new file mode 100644 index 0000000..4e20860 --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ControlledShellToolTest.java @@ -0,0 +1,347 @@ +package com.easyagents.agent.runtime.tool.operate; + +import com.easyagents.agent.runtime.hitl.AgentToolApprovalEvaluation; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.tool.ToolCallParam; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * 测试受控 Shell 策略与执行边界。 + */ +public class ControlledShellToolTest { + + @Test + public void shouldExecuteAllowlistedCommandWithoutLeakingWorkspaceRoot() throws IOException { + Fixture fixture = fixture(); + + String result = execute(fixture.tool(), Map.of("command", "pwd")); + + Assert.assertTrue(result, result.contains("0")); + Assert.assertFalse(result.contains(fixture.root().toString())); + Assert.assertTrue(result, result.contains(".\n")); + } + + @Test + public void shouldRejectOperatorsExpansionAbsolutePathsAndUnknownCommands() throws IOException { + Fixture fixture = fixture(); + + assertRejected(fixture.tool(), "pwd | cat"); + assertRejected(fixture.tool(), "cat $HOME/secret"); + assertRejected(fixture.tool(), "cat /etc/passwd"); + assertRejected(fixture.tool(), "curl https://example.com"); + assertRejected(fixture.tool(), "pwd\ncat secret"); + } + + @Test + public void shouldRestrictScriptEntrypointsAndDangerousRemove() throws IOException { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("safe.py"), "print('ok')\n"); + + assertRejected(fixture.tool(), "python3 -c 'print(1)'"); + assertRejected(fixture.tool(), "python3 -m http.server"); + assertRejected(fixture.tool(), "node --eval '1+1'"); + assertRejected(fixture.tool(), "rm -rf ."); + assertRejected(fixture.tool(), "rm -r -f output"); + assertRejected(fixture.tool(), "rm --recursive --force output"); + Assert.assertTrue(execute(fixture.tool(), Map.of("command", "python3 safe.py")) + .contains("ok")); + } + + @Test + public void shouldExposeOnlyFixedArchiveCommandSet() throws IOException { + Fixture fixture = fixture(); + + assertRejected(fixture.tool(), "tar --checkpoint-action=exec=sh -cf archive.tar input.txt"); + assertRejected(fixture.tool(), "zip -TT sh archive.zip input.txt"); + Assert.assertTrue(ControlledShellTool.DEFAULT_ALLOWED_COMMANDS.containsAll( + Set.of("gzip", "gunzip", "zip", "unzip", "tar"))); + } + + @Test + public void shouldClassifyReadOnlyWriteConversionAndScriptApproval() throws IOException { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("safe.py"), "print('one')\n"); + Files.writeString(fixture.root().resolve("input.pdf"), "%PDF-1.4\n"); + Files.writeString(fixture.root().resolve("input.md"), "# title\n"); + Files.createDirectory(fixture.root().resolve("output")); + + assertApproval(fixture.tool(), "rg --files .", false, false, null); + assertApproval(fixture.tool(), "tree .", false, false, null); + assertApproval(fixture.tool(), "pdftotext input.pdf -", false, false, null); + assertApproval(fixture.tool(), "mkdir generated", true, false, null); + assertApproval(fixture.tool(), "rm generated.txt", true, true, null); + assertApproval(fixture.tool(), "pandoc input.md -o output/report.docx", true, false, null); + assertApproval(fixture.tool(), "soffice --convert-to pdf --outdir output input.md", + true, false, null); + assertApproval(fixture.tool(), "pdftotext input.pdf output/content.txt", true, false, null); + + AgentToolApprovalEvaluation first = fixture.tool().approvalEvaluation( + Map.of("command", "python3 safe.py --mode report")); + Assert.assertTrue(first.valid()); + Assert.assertTrue(first.approvalRequired()); + Assert.assertFalse(first.forced()); + Assert.assertTrue(first.reusableScope().startsWith("SHELL_SCRIPT:")); + + Files.writeString(fixture.root().resolve("safe.py"), "print('two')\n"); + AgentToolApprovalEvaluation changed = fixture.tool().approvalEvaluation( + Map.of("command", "python3 safe.py --mode report")); + Assert.assertNotEquals(first.reusableScope(), changed.reusableScope()); + } + + @Test + public void shouldRejectUnsafeProductivityCommandOptionsBeforeApproval() throws IOException { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("input.md"), "# title\n"); + Files.writeString(fixture.root().resolve("input.pdf"), "%PDF-1.4\n"); + Files.writeString(fixture.root().resolve("paths.txt"), "input.md\n"); + Files.writeString(fixture.root().resolve("args.txt"), "--empty\n"); + Files.createDirectory(fixture.root().resolve("output")); + + for (String command : new String[]{ + "find .", + "tree -l .", + "du --files0-from=paths.txt", + "pandoc input.md --filter cat -o output/report.docx", + "pandoc input.md -ooutput/report.docx", + "pandoc https://example.com -o output/report.docx", + "soffice --accept=socket --convert-to pdf --outdir output input.md", + "pdftoppm /etc/passwd output/page", + "qpdf @args.txt output/result.pdf"}) { + AgentToolApprovalEvaluation evaluation = fixture.tool().approvalEvaluation( + Map.of("command", command)); + Assert.assertFalse(command, evaluation.valid()); + Assert.assertFalse(command, evaluation.approvalRequired()); + } + } + + @Test + public void shouldEnforceTimeoutAndOutputLimit() throws IOException { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("slow.py"), "import time\ntime.sleep(5)\n"); + Files.writeString(fixture.root().resolve("large.py"), "print('x' * 10000)\n"); + + String timeout = execute(fixture.tool(), Map.of("command", "python3 slow.py", "timeout", 1)); + String truncated = execute(fixture.tool(), Map.of("command", "python3 large.py")); + + Assert.assertTrue(timeout.contains("SHELL_TIMEOUT")); + Assert.assertTrue(truncated.contains("truncated=\"true\"")); + Assert.assertTrue(truncated.contains("OUTPUT_TRUNCATED")); + } + + @Test + public void shouldRejectSecondaryExecutionAndIndirectFileOptions() throws IOException { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("input.txt"), "alpha\n"); + Files.writeString(fixture.root().resolve("input.json"), "{\"value\":1}\n"); + Files.writeString(fixture.root().resolve("paths.txt"), "/etc/passwd\n"); + + for (String command : new String[]{ + "awk 'BEGIN { system(\"id\") }' input.txt", + "awk '{ getline value }' input.txt", + "awk '{ print ENVIRON }' input.txt", + "awk -fprogram.awk input.txt", + "awk --profile=profile.txt '{ print }' input.txt", + "sed -e 'e id' input.txt", + "sed '1r input.txt' input.txt", + "sed 's/alpha/beta/w stolen.txt' input.txt", + "sed -i 's/alpha/beta/' input.txt", + "rg --pre cat alpha .", + "rg --pre-glob '*.txt' alpha .", + "rg --hostname-bin pwd alpha .", + "rg -z alpha .", + "rg -L alpha .", + "rg --follow alpha .", + "ls -RL .", + "grep -Rfpatterns.txt alpha .", + "jq 'env' input.json", + "jq '$ENV' input.json", + "jq -Lmodules '.' input.json", + "sort -ooutput.txt input.txt", + "sort --compress-program=cat input.txt", + "uniq input.txt output.txt", + "file -fpaths.txt", + "sha256sum --check paths.txt", + "wc --files0-from=paths.txt", + "tail --follow=name input.txt", + "cp -L input.txt copied.txt", + "cp --symbolic-link input.txt copied.txt", + "cp -l input.txt copied.txt"}) { + assertRejected(fixture.tool(), command); + } + } + + @Test + public void shouldBestEffortTerminateObservedChildAfterSuccessfulScript() throws Exception { + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("spawn.py"), """ + import pathlib + import subprocess + import sys + import time + child = subprocess.Popen( + [sys.executable, '-c', 'import time; time.sleep(30)'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True) + pathlib.Path('child.pid').write_text(str(child.pid), encoding='utf-8') + time.sleep(0.3) + """); + ProcessHandle child = null; + try { + String result = execute(fixture.tool(), Map.of("command", "python3 spawn.py")); + Assert.assertTrue(result, result.contains("0")); + long pid = Long.parseLong(Files.readString(fixture.root().resolve("child.pid")).trim()); + Optional handle = ProcessHandle.of(pid); + child = handle.orElse(null); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (child != null && child.isAlive() && System.nanoTime() < deadline) { + Thread.sleep(20); + } + Assert.assertTrue("Observed child process must be terminated", child == null || !child.isAlive()); + } finally { + if (child != null && child.isAlive()) { + child.destroyForcibly(); + } + } + } + + @Test + public void shouldTerminateLinuxProcessGroupAfterFastParentExit() throws Exception { + assumeLinuxWithPython(); + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("fast-spawn.py"), """ + import pathlib + import subprocess + import sys + child = subprocess.Popen( + [sys.executable, '-c', 'import time; time.sleep(30)'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + pathlib.Path('fast-child.pid').write_text(str(child.pid), encoding='utf-8') + """); + ProcessHandle child = null; + try { + String result = execute(fixture.tool(), Map.of("command", "python3 fast-spawn.py")); + Assert.assertTrue(result, result.contains("0")); + child = ProcessHandle.of(readPid(fixture.root().resolve("fast-child.pid"))).orElse(null); + assertTerminates(child, "Linux process-group child must be terminated after parent exit"); + } finally { + destroyIfAlive(child); + } + } + + @Test + public void shouldTerminateLinuxProcessGroupOnTimeout() throws Exception { + assumeLinuxWithPython(); + Fixture fixture = fixture(); + Files.writeString(fixture.root().resolve("timeout-spawn.py"), """ + import pathlib + import subprocess + import sys + import time + child = subprocess.Popen( + [sys.executable, '-c', 'import time; time.sleep(30)'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + pathlib.Path('timeout-child.pid').write_text(str(child.pid), encoding='utf-8') + time.sleep(30) + """); + ProcessHandle child = null; + try { + String result = execute(fixture.tool(), Map.of( + "command", "python3 timeout-spawn.py", "timeout", 1)); + Assert.assertTrue(result, result.contains("SHELL_TIMEOUT")); + child = ProcessHandle.of(readPid(fixture.root().resolve("timeout-child.pid"))).orElse(null); + assertTerminates(child, "Linux process-group child must be terminated on timeout"); + } finally { + destroyIfAlive(child); + } + } + + private void assumeLinuxWithPython() { + Assume.assumeTrue(System.getProperty("os.name", "").toLowerCase().contains("linux")); + Assume.assumeTrue(Files.isExecutable(Path.of("/usr/bin/python3")) + || Files.isExecutable(Path.of("/usr/local/bin/python3"))); + } + + private long readPid(Path path) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (!Files.exists(path) && System.nanoTime() < deadline) { + Thread.sleep(10); + } + Assert.assertTrue("Child PID file must be created", Files.exists(path)); + return Long.parseLong(Files.readString(path).trim()); + } + + private void assertTerminates(ProcessHandle child, String message) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (child != null && child.isAlive() && System.nanoTime() < deadline) { + Thread.sleep(20); + } + Assert.assertTrue(message, child == null || !child.isAlive()); + } + + private void destroyIfAlive(ProcessHandle process) { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + + private Fixture fixture() throws IOException { + Path root = Files.createTempDirectory("controlled-shell-").toAbsolutePath(); + WorkspacePathGuard pathGuard = new WorkspacePathGuard(root); + WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard( + pathGuard, + new WorkspaceQuotaLimits(1024 * 1024, 1024 * 1024, 100, 1024 * 1024), + WorkspaceQuotaHook.noop()); + AgentOperateToolSpec spec = new AgentOperateToolSpec(); + spec.setShellDefaultTimeout(Duration.ofSeconds(2)); + spec.setShellMaxTimeout(Duration.ofSeconds(3)); + spec.setShellMaxOutputSize(256); + return new Fixture(root, new ControlledShellTool(pathGuard, quotaGuard, spec)); + } + + private String execute(ControlledShellTool tool, Map input) { + ToolResultBlock result = tool.callAsync(ToolCallParam.builder().input(input).build()).block(); + return ((TextBlock) result.getOutput().get(0)).getText(); + } + + private void assertRejected(ControlledShellTool tool, String command) { + String result = execute(tool, Map.of("command", command)); + Assert.assertTrue(result, result.contains("SHELL_COMMAND_DENIED") + || result.contains("WORKSPACE_PATH_INVALID")); + } + + private void assertApproval(ControlledShellTool tool, + String command, + boolean required, + boolean forced, + String scope) { + AgentToolApprovalEvaluation evaluation = tool.approvalEvaluation(Map.of("command", command)); + Assert.assertTrue(command, evaluation.valid()); + Assert.assertEquals(command, required, evaluation.approvalRequired()); + Assert.assertEquals(command, forced, evaluation.forced()); + Assert.assertEquals(command, scope, evaluation.reusableScope()); + } + + /** + * Shell 测试夹具。 + * + * @param root 工作区根 + * @param tool Shell 工具 + */ + private record Fixture(Path root, ControlledShellTool tool) { + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/SafeArchiveCommandExecutorTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/SafeArchiveCommandExecutorTest.java new file mode 100644 index 0000000..3fec570 --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/SafeArchiveCommandExecutorTest.java @@ -0,0 +1,228 @@ +package com.easyagents.agent.runtime.tool.operate; + +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.tool.ToolCallParam; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * 测试 Java 安全归档执行器的创建、展开与恶意条目边界。 + */ +public class SafeArchiveCommandExecutorTest { + + @Test + public void shouldCreateAndExtractGzipZipAndTarArchives() throws IOException { + Fixture fixture = fixture(new WorkspaceQuotaLimits(16 * 1024 * 1024L, + 8 * 1024 * 1024L, 1000, 1024 * 1024)); + Files.createDirectories(fixture.root().resolve("source/nested")); + Files.writeString(fixture.root().resolve("source/nested/value.txt"), "archive-value\n"); + Files.writeString(fixture.root().resolve("plain.txt"), "plain-value\n"); + Files.writeString(fixture.root().resolve("multi-a.txt"), "a\n"); + Files.writeString(fixture.root().resolve("multi-b.txt"), "b\n"); + + assertSuccess(fixture, "gzip -k plain.txt"); + Files.delete(fixture.root().resolve("plain.txt")); + assertSuccess(fixture, "gunzip -k plain.txt.gz"); + Assert.assertEquals("plain-value\n", Files.readString(fixture.root().resolve("plain.txt"))); + assertSuccess(fixture, "gzip multi-a.txt multi-b.txt"); + Assert.assertFalse(Files.exists(fixture.root().resolve("multi-a.txt"))); + Assert.assertFalse(Files.exists(fixture.root().resolve("multi-b.txt"))); + assertSuccess(fixture, "gunzip multi-a.txt.gz multi-b.txt.gz"); + Assert.assertEquals("a\n", Files.readString(fixture.root().resolve("multi-a.txt"))); + Assert.assertEquals("b\n", Files.readString(fixture.root().resolve("multi-b.txt"))); + + assertSuccess(fixture, "zip -q -r bundle.zip source"); + assertSuccess(fixture, "unzip -q bundle.zip -d zip-output"); + Assert.assertEquals("archive-value\n", + Files.readString(fixture.root().resolve("zip-output/source/nested/value.txt"))); + + assertSuccess(fixture, "tar -czf bundle.tar.gz source"); + String listed = execute(fixture, "tar -tzf bundle.tar.gz"); + Assert.assertTrue(listed, listed.contains("source/nested/value.txt")); + Assert.assertFalse(listed.contains(fixture.root().toString())); + assertSuccess(fixture, "tar -xzf bundle.tar.gz -C tar-output"); + Assert.assertEquals("archive-value\n", + Files.readString(fixture.root().resolve("tar-output/source/nested/value.txt"))); + } + + @Test + public void shouldRejectZipSlipDuplicateAndTargetConflictWithoutPartialOutput() throws IOException { + Fixture fixture = fixture(new WorkspaceQuotaLimits(8 * 1024 * 1024L, + 4 * 1024 * 1024L, 1000, 1024 * 1024)); + createZip(fixture.root().resolve("slip.zip"), + new ZipContent("../escape.txt", "escape")); + createZip(fixture.root().resolve("duplicate.zip"), + new ZipContent("same.txt", "first"), new ZipContent("same.txt", "second")); + createZip(fixture.root().resolve("backslash.zip"), + new ZipContent("..\\escape.txt", "escape")); + createZip(fixture.root().resolve("conflict.zip"), + new ZipContent("first.txt", "first"), new ZipContent("second.txt", "second")); + Files.createDirectories(fixture.root().resolve("output")); + Files.writeString(fixture.root().resolve("output/second.txt"), "existing"); + + Assert.assertTrue(execute(fixture, "unzip slip.zip -d slip-output") + .contains("ARCHIVE_ENTRY_DENIED")); + Assert.assertFalse(Files.exists(fixture.root().getParent().resolve("escape.txt"))); + Assert.assertTrue(execute(fixture, "unzip duplicate.zip -d duplicate-output") + .contains("ARCHIVE_DUPLICATE_ENTRY")); + Assert.assertTrue(execute(fixture, "unzip backslash.zip -d backslash-output") + .contains("ARCHIVE_ENTRY_DENIED")); + Assert.assertTrue(execute(fixture, "unzip conflict.zip -d output") + .contains("ARCHIVE_TARGET_CONFLICT")); + Assert.assertFalse(Files.exists(fixture.root().resolve("output/first.txt"))); + Assert.assertEquals("existing", Files.readString(fixture.root().resolve("output/second.txt"))); + } + + @Test + public void shouldRejectTarLinksDevicesAndFifoEntries() throws IOException { + Fixture fixture = fixture(new WorkspaceQuotaLimits(8 * 1024 * 1024L, + 4 * 1024 * 1024L, 1000, 1024 * 1024)); + createTarSpecial(fixture.root().resolve("link.tar"), "link", (byte) '2'); + createTarSpecial(fixture.root().resolve("hard-link.tar"), "hard-link", (byte) '1'); + createTarSpecial(fixture.root().resolve("device.tar"), "device", (byte) '3'); + createTarSpecial(fixture.root().resolve("fifo.tar"), "fifo", (byte) '6'); + + Assert.assertTrue(execute(fixture, "tar -xf link.tar").contains("ARCHIVE_ENTRY_DENIED")); + Assert.assertTrue(execute(fixture, "tar -xf hard-link.tar").contains("ARCHIVE_ENTRY_DENIED")); + Assert.assertTrue(execute(fixture, "tar -xf device.tar").contains("ARCHIVE_ENTRY_DENIED")); + Assert.assertTrue(execute(fixture, "tar -xf fifo.tar").contains("ARCHIVE_ENTRY_DENIED")); + Assert.assertFalse(Files.exists(fixture.root().resolve("link"))); + Assert.assertFalse(Files.exists(fixture.root().resolve("device"))); + Assert.assertFalse(Files.exists(fixture.root().resolve("fifo"))); + } + + @Test + public void shouldRejectExpandedArchiveBeyondQuota() throws IOException { + Fixture fixture = fixture(new WorkspaceQuotaLimits(512, 8 * 1024, 100, 1024)); + createZip(fixture.root().resolve("bomb.zip"), + new ZipContent("expanded.txt", "0".repeat(4096))); + + String output = execute(fixture, "unzip bomb.zip -d expanded"); + + Assert.assertTrue(output, output.contains("WORKSPACE_QUOTA_EXCEEDED")); + Assert.assertFalse(Files.exists(fixture.root().resolve("expanded"))); + } + + @Test + public void shouldRejectUnknownArchiveOptions() throws IOException { + Fixture fixture = fixture(new WorkspaceQuotaLimits(1024 * 1024, 1024 * 1024, 100, 1024)); + Files.writeString(fixture.root().resolve("input.txt"), "input"); + + for (String command : new String[]{ + "gzip -c input.txt", + "zip -T bundle.zip input.txt", + "unzip -o bundle.zip", + "tar --checkpoint-action=exec=id -cf bundle.tar input.txt", + "tar -xf bundle.tar member.txt"}) { + String output = execute(fixture, command); + Assert.assertTrue(command + " => " + output, output.contains("SHELL_COMMAND_DENIED")); + } + } + + @Test + public void shouldCompensateOutputsWhenCommitFailsAfterFirstMove() throws IOException { + Path root = Files.createTempDirectory("safe-archive-compensation-").toAbsolutePath(); + createZip(root.resolve("two-files.zip"), + new ZipContent("first.txt", "first"), new ZipContent("second.txt", "second")); + WorkspacePathGuard pathGuard = new WorkspacePathGuard(root); + WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard( + pathGuard, + new WorkspaceQuotaLimits(1024 * 1024, 1024 * 1024, 100, 1024), + WorkspaceQuotaHook.noop()); + SafeArchiveCommandExecutor executor = new SafeArchiveCommandExecutor( + pathGuard, quotaGuard, 1024, committedCount -> { + if (committedCount == 1) { + throw new IllegalStateException("injected commit failure"); + } + }); + + try { + executor.execute(List.of("unzip", "two-files.zip", "-d", "output"), + System.nanoTime() + TimeUnit.SECONDS.toNanos(5)); + Assert.fail("Expected archive commit failure"); + } catch (WorkspaceToolException expected) { + Assert.assertEquals("ARCHIVE_COMMIT_FAILED", expected.code()); + } + + Assert.assertFalse(Files.exists(root.resolve("output/first.txt"))); + Assert.assertFalse(Files.exists(root.resolve("output/second.txt"))); + Assert.assertFalse(Files.exists(root.resolve("output"))); + } + + private Fixture fixture(WorkspaceQuotaLimits limits) throws IOException { + Path root = Files.createTempDirectory("safe-archive-").toAbsolutePath(); + WorkspacePathGuard pathGuard = new WorkspacePathGuard(root); + WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard(pathGuard, limits, WorkspaceQuotaHook.noop()); + AgentOperateToolSpec spec = new AgentOperateToolSpec(); + spec.setShellDefaultTimeout(Duration.ofSeconds(5)); + spec.setShellMaxTimeout(Duration.ofSeconds(10)); + return new Fixture(root, new ControlledShellTool(pathGuard, quotaGuard, spec)); + } + + private void assertSuccess(Fixture fixture, String command) { + String output = execute(fixture, command); + Assert.assertTrue(command + " => " + output, output.contains("0")); + Assert.assertFalse(output.contains(fixture.root().toString())); + } + + private String execute(Fixture fixture, String command) { + ToolResultBlock result = fixture.tool().callAsync(ToolCallParam.builder() + .input(Map.of("command", command)).build()).block(); + return ((TextBlock) result.getOutput().get(0)).getText(); + } + + private void createZip(Path target, ZipContent... contents) throws IOException { + try (ZipArchiveOutputStream zip = new ZipArchiveOutputStream(target)) { + for (ZipContent content : contents) { + byte[] bytes = content.content().getBytes(StandardCharsets.UTF_8); + ZipArchiveEntry entry = new ZipArchiveEntry(content.name()); + entry.setSize(bytes.length); + zip.putArchiveEntry(entry); + zip.write(bytes); + zip.closeArchiveEntry(); + } + zip.finish(); + } + } + + private void createTarSpecial(Path target, String name, byte linkFlag) throws IOException { + try (OutputStream raw = Files.newOutputStream(target); + TarArchiveOutputStream tar = new TarArchiveOutputStream(raw)) { + TarArchiveEntry entry = new TarArchiveEntry(name, linkFlag); + entry.setSize(0); + if (linkFlag == '2') { + entry.setLinkName("../outside"); + } + tar.putArchiveEntry(entry); + tar.closeArchiveEntry(); + tar.finish(); + } + } + + private record ZipContent(String name, String content) { + } + + /** + * 归档测试夹具。 + * + * @param root 工作区根 + * @param tool Shell 工具 + */ + private record Fixture(Path root, ControlledShellTool tool) { + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ShellProcessGroupSupportTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ShellProcessGroupSupportTest.java new file mode 100644 index 0000000..2103685 --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/ShellProcessGroupSupportTest.java @@ -0,0 +1,35 @@ +package com.easyagents.agent.runtime.tool.operate; + +import org.junit.Assert; +import org.junit.Test; + +import java.nio.file.Path; +import java.util.List; + +/** + * 测试 Linux 独立进程组能力的启动检查与非 Linux 降级。 + */ +public class ShellProcessGroupSupportTest { + + @Test + public void shouldFailFastWhenLinuxProcessGroupDependenciesAreMissing() { + try { + ShellProcessGroupSupport.detect("Linux", null, null); + Assert.fail("Expected missing dependency failure"); + } catch (WorkspaceToolException expected) { + Assert.assertEquals("WORKSPACE_CONFIG_INVALID", expected.code()); + Assert.assertFalse(expected.retryable()); + } + } + + @Test + public void shouldUsePortableFallbackOutsideLinux() { + ShellProcessGroupSupport support = ShellProcessGroupSupport.detect( + "Mac OS X", Path.of("/missing/setsid"), Path.of("/missing/kill")); + List command = List.of("pwd"); + + Assert.assertFalse(support.enabled()); + Assert.assertSame(command, support.wrap(command)); + support.terminate(-1); + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/WorkspaceFileToolsTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/WorkspaceFileToolsTest.java new file mode 100644 index 0000000..3b2c577 --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/WorkspaceFileToolsTest.java @@ -0,0 +1,188 @@ +package com.easyagents.agent.runtime.tool.operate; + +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.ToolCallParam; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 测试安全读写工具与工作区配额。 + */ +public class WorkspaceFileToolsTest { + + @Test + public void shouldWriteAtomicallyAndReadOnlyRequestedRange() throws IOException { + Fixture fixture = fixture(1024, 1024, 10, 1024); + AgentTool write = new SafeWriteFileTool(fixture.pathGuard(), fixture.quotaGuard()).writeTextFileTool(); + AgentTool read = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).viewTextFileTool(); + + ToolResultBlock writeResult = call(write, Map.of( + "file_path", "notes/example.txt", + "content", "first\nsecond\nthird\nfourth\n")); + ToolResultBlock readResult = call(read, Map.of( + "file_path", "notes/example.txt", + "ranges", "2,3")); + + Assert.assertTrue(text(writeResult).contains("successfully")); + Assert.assertTrue(text(readResult).contains("2: second")); + Assert.assertTrue(text(readResult).contains("3: third")); + Assert.assertFalse(text(readResult).contains("1: first")); + Assert.assertFalse(text(readResult).contains(fixture.root().toString())); + try (var files = Files.list(fixture.root().resolve("notes"))) { + Assert.assertTrue(files.noneMatch(path -> path.getFileName().toString().startsWith(".easyagents-write-"))); + } + } + + @Test + public void shouldRejectWriteBeyondQuotaWithoutPartialFile() throws IOException { + Fixture fixture = fixture(5, 5, 1, 5); + AgentTool write = new SafeWriteFileTool(fixture.pathGuard(), fixture.quotaGuard()).writeTextFileTool(); + + ToolResultBlock result = call(write, Map.of("file_path", "too-large.txt", "content", "123456")); + + Assert.assertTrue(text(result).contains("WORKSPACE_QUOTA_EXCEEDED")); + Assert.assertFalse(Files.exists(fixture.root().resolve("too-large.txt"))); + } + + @Test + public void shouldCountCreatedDirectoriesAgainstEntryQuota() throws IOException { + Fixture fixture = fixture(1024, 1024, 1, 1024); + AgentTool write = new SafeWriteFileTool(fixture.pathGuard(), fixture.quotaGuard()).writeTextFileTool(); + + String output = text(call(write, Map.of("file_path", "nested/value.txt", "content", "ok"))); + + Assert.assertTrue(output, output.contains("WORKSPACE_QUOTA_EXCEEDED")); + Assert.assertFalse(Files.exists(fixture.root().resolve("nested"))); + } + + @Test + public void shouldInvokeConfiguredQuotaHook() throws IOException { + Path root = Files.createTempDirectory("workspace-hook-").toAbsolutePath(); + WorkspacePathGuard pathGuard = new WorkspacePathGuard(root); + AtomicInteger writes = new AtomicInteger(); + WorkspaceQuotaHook hook = new WorkspaceQuotaHook() { + @Override + public void beforeRead(Path workspaceRoot, Path target, long requestedBytes) { + } + + @Override + public void beforeWrite(Path workspaceRoot, Path target, long previousBytes, long resultingBytes) { + writes.incrementAndGet(); + } + }; + WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard( + pathGuard, new WorkspaceQuotaLimits(1024, 1024, 10, 1024), hook); + AgentTool write = new SafeWriteFileTool(pathGuard, quotaGuard).writeTextFileTool(); + + call(write, Map.of("file_path", "hook.txt", "content", "ok")); + + Assert.assertEquals(1, writes.get()); + } + + @Test + public void shouldRejectDirectoryListingWhenWorkspaceContainsSymlink() throws IOException { + Fixture fixture = fixture(1024, 1024, 10, 1024); + Files.writeString(fixture.root().resolve("safe.txt"), "safe"); + Path outside = Files.createTempFile("workspace-list-outside-", ".txt"); + try { + Files.createSymbolicLink(fixture.root().resolve("blocked"), outside); + } catch (UnsupportedOperationException error) { + return; + } + AgentTool list = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).listDirectoryTool(); + + String output = text(call(list, Map.of("dir_path", "."))); + + Assert.assertTrue(output, output.contains("WORKSPACE_PATH_INVALID")); + Assert.assertFalse(output.contains(fixture.root().toString())); + Assert.assertFalse(output.contains(outside.toString())); + } + + @Test + public void shouldReadSmallRangeFromFileLargerThanFullReadLimit() throws IOException { + Fixture fixture = fixture(64 * 1024, 64 * 1024, 10, 16); + Files.writeString(fixture.root().resolve("large.txt"), "first\n" + "x".repeat(4096) + "\n"); + AgentTool read = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).viewTextFileTool(); + + String output = text(call(read, Map.of("file_path", "large.txt", "ranges", "1,1"))); + + Assert.assertTrue(output, output.contains("1: first")); + Assert.assertFalse(output.contains("WORKSPACE_QUOTA_EXCEEDED")); + } + + @Test + public void shouldBoundDirectoryListingAndReportTruncation() throws IOException { + Fixture fixture = fixture(64 * 1024, 64 * 1024, 0, 1024); + for (int index = 0; index < 1001; index++) { + Files.writeString(fixture.root().resolve("entry-" + index + ".txt"), "x"); + } + AgentTool list = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).listDirectoryTool(); + + String output = text(call(list, Map.of("dir_path", "."))); + + Assert.assertTrue(output, output.contains("Truncated: true; limit=1000")); + Assert.assertEquals(1000, output.lines().filter(line -> line.startsWith("file\t")).count()); + } + + @Test + public void shouldRejectDirectoryListingBeforeSortingOverQuotaWorkspace() throws IOException { + Fixture fixture = fixture(64 * 1024, 64 * 1024, 3, 1024); + for (int index = 0; index < 4; index++) { + Files.createDirectory(fixture.root().resolve("directory-" + index)); + } + AgentTool list = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).listDirectoryTool(); + + String output = text(call(list, Map.of("dir_path", "."))); + + Assert.assertTrue(output, output.contains("WORKSPACE_QUOTA_EXCEEDED")); + } + + @Test + public void shouldReturnStableReadErrorCodes() throws IOException { + Fixture fixture = fixture(1024, 1024, 10, 1024); + Files.createDirectory(fixture.root().resolve("folder")); + Files.writeString(fixture.root().resolve("valid.txt"), "ok\n"); + AgentTool read = new SafeReadFileTool(fixture.pathGuard(), fixture.quotaGuard()).viewTextFileTool(); + + Assert.assertTrue(text(call(read, Map.of("file_path", "missing.txt"))).contains("FILE_NOT_FOUND")); + Assert.assertTrue(text(call(read, Map.of("file_path", "folder"))).contains("FILE_TYPE_INVALID")); + Assert.assertTrue(text(call(read, Map.of("file_path", "../outside"))) + .contains("WORKSPACE_PATH_INVALID")); + Assert.assertTrue(text(call(read, Map.of("file_path", "valid.txt", "ranges", "x,y"))) + .contains("INVALID_ARGUMENT")); + } + + private Fixture fixture(long total, long single, long count, long read) throws IOException { + Path root = Files.createTempDirectory("workspace-file-tool-").toAbsolutePath(); + WorkspacePathGuard pathGuard = new WorkspacePathGuard(root); + WorkspaceQuotaGuard quotaGuard = new WorkspaceQuotaGuard( + pathGuard, new WorkspaceQuotaLimits(total, single, count, read), WorkspaceQuotaHook.noop()); + return new Fixture(root, pathGuard, quotaGuard); + } + + private ToolResultBlock call(AgentTool tool, Map input) { + return tool.callAsync(ToolCallParam.builder().input(input).build()).block(); + } + + private String text(ToolResultBlock result) { + return ((TextBlock) result.getOutput().get(0)).getText(); + } + + /** + * 测试工具夹具。 + * + * @param root 工作区根 + * @param pathGuard 路径保护器 + * @param quotaGuard 配额保护器 + */ + private record Fixture(Path root, WorkspacePathGuard pathGuard, WorkspaceQuotaGuard quotaGuard) { + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/WorkspacePathGuardTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/WorkspacePathGuardTest.java new file mode 100644 index 0000000..5d848b4 --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/tool/operate/WorkspacePathGuardTest.java @@ -0,0 +1,121 @@ +package com.easyagents.agent.runtime.tool.operate; + +import com.easyagents.agent.runtime.AgentRuntimeException; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * 测试工作区路径边界。 + */ +public class WorkspacePathGuardTest { + + @Test + public void shouldRejectAbsoluteTraversalAndTildePaths() throws IOException { + WorkspacePathGuard guard = guard(); + + assertRejectedWithout(() -> guard.resolveForWrite("/etc/passwd"), "/etc/passwd"); + assertRejected(() -> guard.resolveForWrite("../escape.txt")); + assertRejected(() -> guard.resolveForWrite("~/secret.txt")); + assertRejected(() -> guard.resolveForWrite("C:\\Windows\\system.ini")); + } + + @Test + public void shouldRejectSymbolicLinkEscape() throws IOException { + Path root = Files.createTempDirectory("workspace-path-"); + Path outside = Files.createTempDirectory("workspace-outside-"); + try { + Files.createSymbolicLink(root.resolve("link"), outside); + } catch (UnsupportedOperationException error) { + Assume.assumeNoException(error); + } + WorkspacePathGuard guard = new WorkspacePathGuard(root.toAbsolutePath()); + + assertRejected(() -> guard.resolveForWrite("link/secret.txt")); + } + + @Test + public void shouldRejectHardLinkedFileOnUnix() throws IOException { + Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("unix")); + Path root = Files.createTempDirectory("workspace-hardlink-"); + Files.writeString(root.resolve("source.txt"), "secret"); + Files.createLink(root.resolve("alias.txt"), root.resolve("source.txt")); + WorkspacePathGuard guard = new WorkspacePathGuard(root.toAbsolutePath()); + + assertRejected(() -> guard.resolveExistingFile("alias.txt")); + } + + @Test + public void shouldRejectSymlinkReplacementBetweenResolveAndAtomicCommit() throws IOException { + Path root = Files.createTempDirectory("workspace-replacement-"); + Path outside = Files.createTempFile("workspace-replacement-outside-", ".txt"); + Files.writeString(outside, "outside"); + WorkspacePathGuard guard = new WorkspacePathGuard(root.toAbsolutePath()); + Path target = guard.resolveForWrite("target.txt"); + try { + Files.createSymbolicLink(target, outside); + } catch (UnsupportedOperationException error) { + Assume.assumeNoException(error); + } + + assertRejected(() -> WorkspaceTextFiles.atomicWrite( + guard, target, "changed".getBytes(StandardCharsets.UTF_8))); + + Assert.assertEquals("outside", Files.readString(outside)); + } + + @Test + public void shouldRejectHardLinkReplacementBetweenResolveAndAtomicCommitOnUnix() throws IOException { + Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("unix")); + Path root = Files.createTempDirectory("workspace-hardlink-replacement-"); + Path outside = Files.createTempFile("workspace-hardlink-outside-", ".txt"); + Files.writeString(outside, "outside"); + WorkspacePathGuard guard = new WorkspacePathGuard(root.toAbsolutePath()); + Path target = guard.resolveForWrite("target.txt"); + Files.createLink(target, outside); + + assertRejected(() -> WorkspaceTextFiles.atomicWrite( + guard, target, "changed".getBytes(StandardCharsets.UTF_8))); + + Assert.assertEquals("outside", Files.readString(outside)); + } + + @Test + public void shouldDisplayOnlyRelativePath() throws IOException { + Path root = Files.createTempDirectory("workspace-display-"); + WorkspacePathGuard guard = new WorkspacePathGuard(root.toAbsolutePath()); + Path target = guard.resolveForWrite("output/report.txt"); + + Assert.assertEquals(".", guard.display(root.toRealPath())); + Assert.assertEquals("output/report.txt", guard.display(target)); + Assert.assertFalse(guard.display(target).contains(root.toString())); + } + + private WorkspacePathGuard guard() throws IOException { + return new WorkspacePathGuard(Files.createTempDirectory("workspace-path-").toAbsolutePath()); + } + + private void assertRejected(Runnable action) { + try { + action.run(); + Assert.fail("Expected AgentRuntimeException"); + } catch (AgentRuntimeException expected) { + Assert.assertFalse(expected.getMessage().contains("/Users/")); + } + } + + private void assertRejectedWithout(Runnable action, String forbiddenText) { + try { + action.run(); + Assert.fail("Expected AgentRuntimeException"); + } catch (AgentRuntimeException expected) { + Assert.assertFalse(expected.getMessage().contains(forbiddenText)); + } + } +}