From 85f679ad4af9c281327b8b9e98f771dfa1e19f16 Mon Sep 17 00:00:00 2001 From: "everywhere.z" Date: Sat, 20 Jun 2026 10:21:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E6=81=A2=E5=A4=8D=E8=87=AA?= =?UTF-8?q?=E5=BB=BA=E6=96=87=E4=BB=B6/shell=20=E5=B7=A5=E5=85=B7=EF=BC=88?= =?UTF-8?q?ManagedShellCommandTool=20=E4=B8=BA=20ToolBase+matchRule=20?= =?UTF-8?q?=E6=8E=A5=20PermissionEngine=EF=BC=9BWorkspaceFileTools=20?= =?UTF-8?q?=E6=B3=A8=E8=A7=A3=E5=BC=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3.2: 从 1.0 恢复 WorkspaceFileTools(注解式,原样)与 ManagedShellCommandTool (改写为 ToolBase 子类,覆写 matchRule 做 per-token equals,使 Task 3.1 的命令级 PermissionEngine 规则真正生效)。移除工具内白/黑名单/DISABLED 判断(交 PermissionEngine), 保留管道/链拒绝 + ProcessBuilder + timeout + maxOutputBytes。ReactAgentFactory 按 enableWorkspaceFileTools/enableShellTool(mode!=DISABLED)注册。un-stub 两个 sibling 组件测试,新增 ShellPermissionBehaviorTest 验证三模式裁决矩阵。探针结论 R6-ext2 回填 v2-api-findings.md(含 Builder 无 build() 的源码校正)。 Co-Authored-By: Claude Fable 5 --- docs/superpowers/specs/v2-api-findings.md | 52 +++++ .../agent/component/ReactAgentFactory.java | 35 +++ .../agent/tool/ManagedShellCommandTool.java | 218 ++++++++++++++++++ .../agent/tool/WorkspaceFileTools.java | 109 +++++++++ .../shelltool/ShellToolModesFeatureTest.java | 42 ++-- .../feature/shelltool/ShellToolsAgentCmp.java | 48 ++-- .../WorkspaceToolsAgentCmp.java | 45 ++-- .../agent/v2/ShellPermissionBehaviorTest.java | 113 +++++++++ 8 files changed, 608 insertions(+), 54 deletions(-) create mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/ManagedShellCommandTool.java create mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/WorkspaceFileTools.java create mode 100644 liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ShellPermissionBehaviorTest.java diff --git a/docs/superpowers/specs/v2-api-findings.md b/docs/superpowers/specs/v2-api-findings.md index 3f6e1b5d7..a30167254 100644 --- a/docs/superpowers/specs/v2-api-findings.md +++ b/docs/superpowers/specs/v2-api-findings.md @@ -310,3 +310,55 @@ PermissionContextState.builder() - JUnit Platform Launcher(`junit-platform-launcher:1.8.2`,匹配 jupiter 5.8.2)执行 `selectClass(V2ApiProbe)` → **6 tests, 0 failed**。 探针源文件本身放在 brief 指定路径,是可被未来 `mvn test` 直接运行的合规 JUnit5 测试;当前仅因 sibling 测试未随 Task 0 一并更新而无法用单条 mvn 命令跑通。后续 Task 重建 feature 测试后该 mvn 命令自然恢复可用。 + +--- + +## R6-ext2:ToolBase 子类定义 + Toolkit 注册 + matchRule 精确签名(Task 3.2 探针确认) + +**来源:** 对 `agentscope-2.0.0-RC3-sources.jar` 中 `io/agentscope/core/tool/ToolBase.java`、`Toolkit.java`、`AgentTool.java`、`ToolCallParam.java`、`ReflectiveFunctionTool.java` 全量源码通读(JDK 21,直接读 RC3 源码,比反射更权威)。结论已用于 Task 3.2 `ManagedShellCommandTool` 改写。 + +### (a) 如何定义一个 `ToolBase` 子类 + +`io.agentscope.core.tool.ToolBase` 是 `abstract implements AgentTool`。子类通过两个构造器形态之一初始化元数据: + +1. **Builder 构造器(推荐)**:`protected ToolBase(Builder builder)`,配合 `super(ToolBase.builder().name(...).description(...).inputSchema(...).readOnly(bool).concurrencySafe(bool).build())`。 +2. **位置参数构造器**:`protected ToolBase(String name, String description, Map inputSchema, boolean readOnly, boolean concurrencySafe, boolean mcp, String mcpName, boolean externalTool, boolean stateInjected)`。 + +`ToolBase.Builder` 全量方法(实测):`name, description, inputSchema(Map), readOnly(bool), concurrencySafe(bool), externalTool(bool), stateInjected(bool), mcp(String mcpName), dangerousFiles(List), dangerousDirectories(List)`。**注意:Builder 没有 `build()` 方法**(类级 javadoc 示例写了 `.build()` 但属过时文档,源码中 `Builder` class 无 `build()`)。子类构造器直接 `super(ToolBase.builder().name(...)...concurrencySafe(true))`(把 Builder 实例原样传给 `protected ToolBase(Builder)`,构造器读 Builder 的私有字段)。**无 `.callAsync(...)` 之类的执行体配置**——执行体由子类覆写 `AgentTool.callAsync(ToolCallParam)` 提供(返回 `Mono`)。 + +子类必须覆写 `callAsync`(否则基类抛 `UnsupportedOperationException`,除非 `externalTool=true`)。基类 `callAsync` 签名: +```java +@Override +public Mono callAsync(ToolCallParam param) // 非 final,可覆写 +``` + +`inputSchema` 是 JSON-Schema 形态的 `Map`(如 `{"type":"object","properties":{...},"required":[...]}`),由子类自构造(基类不生成 schema)。 + +### (b) `matchRule(String content, Map input)` 精确签名 + +```java +public boolean matchRule(String ruleContent, Map toolInput) +// 默认实现:return ruleContent == null; +// 非 final;子类可覆写。 +``` +- 第一参数 `ruleContent`:来自 `PermissionRule.ruleContent()`(裸字符串,由 `PermissionConfigMapper` 产出,约定为首 token 如 `"ls"`;catch-all 传 `null`)。 +- 第二参数 `toolInput`:即工具收到的 `toolInput`(`PermissionEngine.checkPermission(ToolBase, Map)` 传入,字段名 = 工具参数名)。对 `ManagedShellCommandTool` 就是 `{"command": "<用户命令字符串>"}`。 +- `PermissionEngine.ruleMatches` 实测源码:`content==null||isEmpty` → 返回 `true`(catch-all);否则 `return tool.matchRule(content, input)`。 + +### (c) 如何把 `ToolBase` 子类实例注册进 `Toolkit` + +`Toolkit.registerTool(Object)`(公开方法)内部对入参先做 `instanceof AgentTool` 分支:**是 `AgentTool`(`ToolBase` 实现了它)则走 `registerAgentTool(agentTool, ...)`,否则才扫描 `@Tool` 注解**。故 `toolkit.registerTool(myToolBaseInstance)` 等价于直接注册该实例(保留子类覆写的 `matchRule`/`callAsync`)。 + +另有更直白的 `Toolkit.registerAgentTool(AgentTool)`,两者效果一致。`Toolkit.registration().agentTool(AgentTool).apply()` 是 fluent 变体。**推荐用 `registerTool(Object)`**——与注解式路径同一入口,factory 代码统一。 + +### (d) 注解式 `@Tool` 工具是否仍可 `registerTool(Object)` + +**仍可。** `registerTool(Object)` 对非 `AgentTool` 入参扫描 `@Tool`/`@ToolParam` 注解方法,逐个经 `ReflectiveFunctionTool.create(...)` 包成 `ToolBase` 子类实例后注册。`ReflectiveFunctionTool extends ToolBase`,**但未覆写 `matchRule`**——故注解式工具用基类默认 `matchRule = (ruleContent == null)`,只能被 catch-all 规则 gate,无法做 per-token 匹配。 + +**结论(指导 Task 3.2):** +- `WorkspaceFileTools`(1.0 注解式,无 per-token 权限语义)→ 原样注解式恢复,`registerTool(Object)` 注册,靠 catch-all 规则(若配置)。 +- `ManagedShellCommandTool`(需 per-token 命令规则)→ **必须**是 `ToolBase` 子类(非注解式),覆写 `matchRule`,`registerTool(Object)` 注册。否则 `PermissionConfigMapper` 产出的 per-token allow/deny 规则(`ruleContent="ls"`/`"rm"`)全部落到基类默认 `matchRule`,因 `ruleContent != null` 永远返回 `false`,规则永不命中——白/黑名单失效。 + +### `ToolCallParam` 取参路径(callAsync 实现用) + +`ToolCallParam.getInput():Map`(unmodifiable)即工具参数;`getToolUseBlock()` 含 call id/name;`getAgent()`/`getRuntimeContext()` 可空。子类 `callAsync` 实现典型:从 `param.getInput().get("command")` 取参、执行、返回 `Mono.just(new ToolResultBlock(...))`。 diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java index fa564061a..7e1ec7100 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java @@ -3,13 +3,18 @@ package com.yomahub.liteflow.agent.component; import com.yomahub.liteflow.agent.exception.AgentConfigException; import com.yomahub.liteflow.agent.permission.PermissionConfigMapper; import com.yomahub.liteflow.agent.state.AgentStateStoreResolver; +import com.yomahub.liteflow.agent.tool.ManagedShellCommandTool; +import com.yomahub.liteflow.agent.tool.WorkspaceFileTools; import com.yomahub.liteflow.property.agent.AgentConfig; +import com.yomahub.liteflow.property.agent.ShellMode; import io.agentscope.core.ReActAgent; import io.agentscope.core.model.Model; import io.agentscope.core.permission.PermissionContextState; import io.agentscope.core.state.AgentStateStore; import io.agentscope.core.tool.Toolkit; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import java.util.concurrent.ConcurrentHashMap; @@ -116,6 +121,21 @@ public final class ReactAgentFactory { } } + // 自建 workspace 文件工具(read_file/write_file/list_files/delete_file)。 + // 工具实例绑定 cfg 配置的 workspace 根目录——agent 按 cmp 子类缓存为单例,无法持有 + // per-(conversationId) 子目录(RC3 无 HarnessAgent 做 session 桶,那是后续 GA 范围)。 + if (cmp.enableWorkspaceFileTools()) { + toolkit.registerTool(new WorkspaceFileTools(workspaceRoot(cfg), cfg)); + } + // 受管 shell 工具(execute_shell_command,ToolBase 子类,覆写 matchRule 接 PermissionEngine)。 + // 仅当显式开启且 mode != DISABLED 时注册——DISABLED 时工具不注册,模型连 schema 都看不到, + // 更干净(与 1.0 语义一致;DISABLED 的 deny 由 PermissionConfigMapper 兜底,双保险)。 + if (cmp.enableShellTool() + && cfg.getShell() != null + && cfg.getShell().getMode() != ShellMode.DISABLED) { + toolkit.registerTool(new ManagedShellCommandTool(workspaceRoot(cfg), cfg)); + } + int maxIters = cmp.maxIterations(); if (maxIters <= 0) { maxIters = cfg.getDefaults().getMaxIterations(); @@ -142,4 +162,19 @@ public final class ReactAgentFactory { "Failed to build ReActAgent for component " + name + ": " + e.getMessage(), e); } } + + /** + * 解析 cfg 配置的 workspace 根目录(用于绑定自建 workspace/shell 工具实例)。 + * + *

与 {@link ReActAgentComponent#workspaceRoot(AgentConfig, String)} 同一套回退规则, + * 但不带 {@code conversationId}——agent 是按 cmp 子类缓存的单例,工具实例随之跨调用 + * 复用,故绑定到 cfg 配置的目录。per-session 子桶是后续 HarnessAgent/GA 的范围。 + */ + private static Path workspaceRoot(AgentConfig cfg) { + String root = cfg.getWorkspace().getRoot(); + if (root == null || root.isBlank()) { + return Paths.get(System.getProperty("java.io.tmpdir"), "liteflow-agent-workspace"); + } + return Paths.get(root); + } } diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/ManagedShellCommandTool.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/ManagedShellCommandTool.java new file mode 100644 index 000000000..5f4c598f0 --- /dev/null +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/ManagedShellCommandTool.java @@ -0,0 +1,218 @@ +package com.yomahub.liteflow.agent.tool; + +import com.yomahub.liteflow.agent.permission.PermissionConfigMapper; +import com.yomahub.liteflow.property.agent.AgentConfig; +import com.yomahub.liteflow.property.agent.ShellConfig; +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 io.agentscope.core.tool.ToolBase; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * 受管 Shell 命令工具({@code execute_shell_command}),在 {@code workspaceRoot} 下以 + * {@link ProcessBuilder} 执行单条命令,拒绝管道 / 重定向 / 命令链,并对执行时长与输出体积限流。 + * + *

v2(RC3)状态:Task 3.2 从 1.0 改写为 {@link ToolBase} 子类(非注解式)。 + * 1.0 在工具内手写的白 / 黑名单 / DISABLED 裁决已移除——改由 v2 + * {@code PermissionEngine} 在工具调用前裁决(Task 3.1 的 {@link PermissionConfigMapper} + * 把 {@link ShellConfig} 映射为命令级 {@code PermissionContextState} 规则)。本工具只保留: + *

    + *
  • 拒绝管道 / 重定向 / 命令链({@code | < > && || ;},1.0 安全逻辑);
  • + *
  • {@code timeout}:到时强杀子进程;
  • + *
  • {@code maxOutputBytes}:截断 stdout,防止 LLM 上下文被巨量输出塞满。
  • + *
+ * + *

matchRule(findings R6-ext / R6-ext2)

+ * 本类覆写 {@link #matchRule(String, Map)}:取 {@code toolInput.get("command")} 的首 token, + * 与 {@code ruleContent} 字符串 {@code equals} 比对。这与 {@link PermissionConfigMapper} + * 产出的 per-token 规则({@code ruleContent = "<首 token>"},如 {@code "ls"} / {@code "rm"}) + * 共同约定。{@code ruleContent} 为 {@code null} / 空 → 匹配一切(catch-all)。 + * + *

为什么必须是 {@code ToolBase} 子类而非注解式:注解式经 + * {@code ReflectiveFunctionTool} 注册,它不覆写 {@code matchRule},用基类默认 + * {@code return ruleContent == null;}——对非 null 的 per-token 规则永远返回 {@code false}, + * 命令级 allow / deny 规则永不命中,白 / 黑名单失效。只有覆写 {@code matchRule} 的 + * {@code ToolBase} 子类才能让 per-token 规则真正生效。 + */ +public class ManagedShellCommandTool extends ToolBase { + + /** 工具参数名(与 1.0 / {@link PermissionConfigMapper#SHELL_TOOL_NAME} 规则 target 一致)。 */ + private static final String PARAM_COMMAND = "command"; + + private final Path workspace; + private final ShellConfig shell; + + public ManagedShellCommandTool(Path workspace, AgentConfig cfg) { + super(ToolBase.builder() + .name(PermissionConfigMapper.SHELL_TOOL_NAME) + .description("Execute a controlled shell command in the current workspace. " + + "Pipes, redirection, and command chaining are rejected.") + .inputSchema(commandSchema()) + .readOnly(false) + .concurrencySafe(true)); + this.workspace = workspace.toAbsolutePath().normalize(); + this.shell = cfg.getShell(); + } + + /* ===== matchRule:首 token equals(findings R6-ext / R6-ext2)===== */ + + /** + * 命令级规则匹配:{@code ruleContent} 与命令首 token 精确比对。 + * + *

{@code ruleContent} 由 {@link PermissionConfigMapper} 产出,约定为裸首 token + * (如 {@code "ls"} / {@code "rm"});{@code null} / 空 = catch-all(匹配一切)。 + * + * @param ruleContent 规则内容(裸首 token 或 null/空) + * @param toolInput 工具入参,期望含 {@code "command"} 字段 + * @return 首 token equals {@code ruleContent},或 {@code ruleContent} 为 null/空 + */ + @Override + public boolean matchRule(String ruleContent, Map toolInput) { + if (ruleContent == null || ruleContent.isEmpty()) { + return true; + } + Object cmdObj = toolInput == null ? null : toolInput.get(PARAM_COMMAND); + if (cmdObj == null) { + return false; + } + String first = firstToken(cmdObj.toString()); + return ruleContent.equals(first); + } + + /* ===== 执行体(保留 1.0 安全逻辑,移除白/黑名单/DISABLED)===== */ + + @Override + public Mono callAsync(ToolCallParam param) { + Object cmdObj = param.getInput().get(PARAM_COMMAND); + String command = cmdObj == null ? null : cmdObj.toString(); + String result = run(command); + String callId = param.getToolUseBlock() != null ? param.getToolUseBlock().getId() : null; + return Mono.just(ToolResultBlock.of(callId, getName(), + TextBlock.builder().text(result).build())); + } + + /** + * 同步执行单条命令(保留 1.0 管道/链拒绝 + ProcessBuilder + timeout + maxOutputBytes)。 + * 白 / 黑名单 / DISABLED 判断已移除——交由 v2 PermissionEngine 裁决。 + */ + private String run(String command) { + if (command == null || command.isBlank()) { + return "{\"error\":\"empty command\"}"; + } + if (containsUnsupportedShellSyntax(command)) { + return "{\"error\":\"unsupported shell syntax: pipes, redirection, and command chaining are not supported\"}"; + } + String[] tokens = command.trim().split("\\s+"); + try { + ProcessBuilder pb = new ProcessBuilder(tokens); + pb.directory(workspace.toFile()); + pb.redirectErrorStream(true); + Process p = pb.start(); + closeQuietly(p.getOutputStream()); + ExecutorService outputReader = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "liteflow-agent-shell-output-reader"); + t.setDaemon(true); + return t; + }); + Future outputFuture = outputReader.submit( + () -> readLimited(p.getInputStream(), shell.getMaxOutputBytes())); + try { + boolean done = p.waitFor(shell.getTimeout().toMillis(), TimeUnit.MILLISECONDS); + if (!done) { + p.destroyForcibly(); + closeQuietly(p.getInputStream()); + outputFuture.cancel(true); + return "{\"error\":\"timeout after " + shell.getTimeout().toMillis() + "ms\"}"; + } + return outputFuture.get(1, TimeUnit.SECONDS); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + return "{\"error\":\"" + (cause == null ? e.getMessage() : cause.getMessage()).replace("\"", "'") + "\"}"; + } catch (TimeoutException e) { + outputFuture.cancel(true); + return "{\"error\":\"output read timeout\"}"; + } finally { + outputReader.shutdownNow(); + } + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + return "{\"error\":\"" + e.getMessage().replace("\"", "'") + "\"}"; + } + } + + private static boolean containsUnsupportedShellSyntax(String command) { + return command.contains("|") + || command.contains("<") + || command.contains(">") + || command.contains("&&") + || command.contains("||") + || command.contains(";"); + } + + private static void closeQuietly(java.io.Closeable closeable) { + if (closeable == null) { + return; + } + try { + closeable.close(); + } catch (IOException ignored) { + // ignore close failures while cleaning up process streams + } + } + + private static String readLimited(InputStream in, long max) throws IOException { + byte[] buf = new byte[4096]; + List chunks = new ArrayList<>(); + long total = 0; + int n; + while ((n = in.read(buf)) > 0 && total < max) { + int toCopy = (int) Math.min(n, max - total); + byte[] c = new byte[toCopy]; + System.arraycopy(buf, 0, c, 0, toCopy); + chunks.add(c); + total += toCopy; + } + byte[] all = new byte[(int) total]; + int pos = 0; + for (byte[] c : chunks) { System.arraycopy(c, 0, all, pos, c.length); pos += c.length; } + return new String(all, StandardCharsets.UTF_8); + } + + private static String firstToken(String command) { + if (command == null) return ""; + String trimmed = command.trim(); + if (trimmed.isEmpty()) return ""; + return trimmed.split("\\s+", 2)[0]; + } + + /** 构造 {@code command} 单字符串参数的 JSON-Schema map。 */ + private static Map commandSchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + Map properties = new LinkedHashMap<>(); + Map commandProp = new LinkedHashMap<>(); + commandProp.put("type", "string"); + commandProp.put("description", "Single command string (pipes && || are rejected)"); + properties.put(PARAM_COMMAND, commandProp); + schema.put("properties", properties); + schema.put("required", List.of(PARAM_COMMAND)); + return schema; + } +} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/WorkspaceFileTools.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/WorkspaceFileTools.java new file mode 100644 index 000000000..c39c7e388 --- /dev/null +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/WorkspaceFileTools.java @@ -0,0 +1,109 @@ +package com.yomahub.liteflow.agent.tool; + +import com.yomahub.liteflow.property.agent.AgentConfig; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.ArrayList; +import java.util.List; + +/** + * 工作区文件工具集(read_file / write_file / list_files / delete_file),按 + * {@code workspaceRoot} 隔离,路径越界(绝对路径或 {@code ..} 逃逸)一律拒绝。 + * + *

v2(RC3)状态:本类为 Task 3.2 从 1.0 原样恢复的注解式工具。v2 的 + * {@code Toolkit.registerTool(Object)} 仍扫描 {@link Tool}/{@link ToolParam} 注解方法, + * 逐个经 {@code ReflectiveFunctionTool} 包成 {@code ToolBase} 子类注册。注解式工具 + * 用 {@code ToolBase} 的默认 {@code matchRule}({@code ruleContent == null} 才匹配), + * 故只受 catch-all 权限规则约束——本类无 per-token 命令语义,无需覆写 {@code matchRule}。 + * 命令级权限门控仅对 {@code ManagedShellCommandTool}(同为 Task 3.2 恢复,ToolBase 子类) + * 有意义。 + * + *

限流字段({@code maxBytes} / {@code maxList})取自 {@link AgentConfig#getWorkspace()}。 + */ +public class WorkspaceFileTools { + + private final Path workspace; + private final long maxBytes; + private final int maxList; + + public WorkspaceFileTools(Path workspace, AgentConfig cfg) { + this.workspace = workspace.toAbsolutePath().normalize(); + this.maxBytes = cfg.getWorkspace().getMaxFileBytes(); + this.maxList = cfg.getWorkspace().getMaxListSize(); + } + + @Tool(name = "read_file", description = "Read a text file in the current workspace") + public String readFile( + @ToolParam(name = "path", description = "Relative path") String path) { + Path p = resolveSafe(path); + try { + long size = Files.size(p); + if (size > maxBytes) { + byte[] buf = new byte[(int) maxBytes]; + try (var in = Files.newInputStream(p)) { + int read = in.read(buf); + return new String(buf, 0, Math.max(0, read), StandardCharsets.UTF_8); + } + } + return Files.readString(p, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("read_file failed: " + e.getMessage(), e); + } + } + + @Tool(name = "write_file", description = "Write text to a file in the current workspace (overwrite)") + public String writeFile( + @ToolParam(name = "path", description = "Relative path") String path, + @ToolParam(name = "content", description = "File content") String content) { + Path p = resolveSafe(path); + try { + Files.createDirectories(p.getParent()); + Files.writeString(p, content, StandardCharsets.UTF_8); + return "ok"; + } catch (IOException e) { + throw new RuntimeException("write_file failed: " + e.getMessage(), e); + } + } + + @Tool(name = "list_files", description = "List files in a workspace directory") + public List listFiles( + @ToolParam(name = "path", required = false, description = "Relative path; defaults to current dir") String path) { + Path dir = resolveSafe(path == null || path.isEmpty() ? "." : path); + List out = new ArrayList<>(); + try (var ds = Files.newDirectoryStream(dir)) { + for (Path p : ds) { + out.add(workspace.relativize(p).toString()); + if (out.size() >= maxList) break; + } + } catch (IOException e) { + throw new RuntimeException("list_files failed: " + e.getMessage(), e); + } + return out; + } + + @Tool(name = "delete_file", description = "Delete a file in the current workspace") + public String deleteFile( + @ToolParam(name = "path", description = "Relative path") String path) { + Path p = resolveSafe(path); + try { + Files.deleteIfExists(p); + return "ok"; + } catch (IOException e) { + throw new RuntimeException("delete_file failed: " + e.getMessage(), e); + } + } + + private Path resolveSafe(String rel) { + if (rel == null) throw new SecurityException("path is null"); + if (rel.startsWith("/")) throw new SecurityException("absolute path denied: " + rel); + Path abs = workspace.resolve(rel).toAbsolutePath().normalize(); + if (!abs.startsWith(workspace)) { + throw new SecurityException("path escapes workspace: " + rel); + } + return abs; + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/shelltool/ShellToolModesFeatureTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/shelltool/ShellToolModesFeatureTest.java index 443b0a5b0..2ed7e5f32 100644 --- a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/shelltool/ShellToolModesFeatureTest.java +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/shelltool/ShellToolModesFeatureTest.java @@ -12,12 +12,21 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.ComponentScan; import org.springframework.test.context.TestPropertySource; -import java.util.List; import java.util.Set; /** * 覆盖 guide §6.4 受管 Shell 工具的三种模式:DISABLED / BLACKLIST / WHITELIST。 * 每个测试都在同一条 THEN 链路中调用 Agent,但 BeforeEach 调整全局 shell.mode。 + * + *

v2(RC3)状态(Task 3.2 更新):命令级白/黑名单裁决已从工具内移到 + * PermissionEngine(Task 3.1 {@code PermissionConfigMapper})。本功能测试只验证: + *

    + *
  • 工具注册受 mode 门控(DISABLED → 不注册;BLACKLIST/WHITELIST → 注册)——PROBE 抓 toolkit;
  • + *
  • 工具执行体在允许场景下可用({@code pwd} 在 workspace 下执行)。
  • + *
+ * 命令级拒绝(rm 被 blacklist/whitelist 拒)由独立的 {@code ShellPermissionBehaviorTest} + * 在 PermissionEngine 层验证——本测试不再断言"工具内返回的拒绝消息"(v2 下拒绝发生在引擎, + * 工具根本不会被调用)。 */ @TestPropertySource("classpath:/feature/shelltool/application.properties") @SpringBootTest(classes = ShellToolModesFeatureTest.class) @@ -32,7 +41,7 @@ public class ShellToolModesFeatureTest extends BaseAgentLiveTest { } @Test - public void testDisabledModeSkipsToolRegistrationAndDeniesExecution() { + public void testDisabledModeSkipsToolRegistration() { liteflowConfig.getAgent().getShell().setMode(ShellMode.DISABLED); LiteflowResponse response = flowExecutor.execute2Resp( @@ -44,13 +53,13 @@ public class ShellToolModesFeatureTest extends BaseAgentLiveTest { Assertions.assertFalse(tools.contains("execute_shell_command"), "Shell mode=DISABLED 时不应注册 execute_shell_command"); - // DISABLED 模式应返回拒绝消息。 - Assertions.assertNotNull(ShellToolsAgentCmp.PWD_OUTPUT.get()); - Assertions.assertTrue(ShellToolsAgentCmp.PWD_OUTPUT.get().contains("shell execution denied by policy")); + // DISABLED 模式下工具执行体也不应被触发(与"工具未注册"语义一致)。 + Assertions.assertNull(ShellToolsAgentCmp.PWD_OUTPUT.get(), + "DISABLED 模式不应执行 shell 工具"); } @Test - public void testBlacklistModeRegistersToolAndBlocksDangerousFirstToken() { + public void testBlacklistModeRegistersToolAndRunsHarmlessCommand() { liteflowConfig.getAgent().getShell().setMode(ShellMode.BLACKLIST); LiteflowResponse response = flowExecutor.execute2Resp( @@ -63,19 +72,16 @@ public class ShellToolModesFeatureTest extends BaseAgentLiveTest { Assertions.assertTrue(tools.contains("execute_shell_command"), "Shell mode=BLACKLIST 时应注册 execute_shell_command"); - // pwd 在默认 blacklist 中不存在,应正常执行并返回当前 workspace。 + // pwd 不在默认 blacklist 中,工具执行体应正常运行并返回当前 workspace。 String pwd = ShellToolsAgentCmp.PWD_OUTPUT.get(); - Assertions.assertNotNull(pwd); + Assertions.assertNotNull(pwd, "BLACKLIST 模式应允许 pwd 执行"); Assertions.assertEquals(ShellToolsAgentCmp.WORKSPACE.get(), pwd.trim()); - - // rm 在默认 blacklist 中,应返回 not allowed by blacklist。 - Assertions.assertTrue(ShellToolsAgentCmp.BLOCKED_OUTPUT.get().contains("not allowed by blacklist")); } @Test - public void testWhitelistModeOnlyAllowsListedCommands() { + public void testWhitelistModeRegistersTool() { liteflowConfig.getAgent().getShell().setMode(ShellMode.WHITELIST); - liteflowConfig.getAgent().getShell().setWhitelist(List.of("pwd")); + liteflowConfig.getAgent().getShell().setWhitelist(java.util.List.of("pwd")); LiteflowResponse response = flowExecutor.execute2Resp( "shellToolsChain", "请用一句话作答。"); @@ -84,14 +90,12 @@ public class ShellToolModesFeatureTest extends BaseAgentLiveTest { "chain failed: " + (response.getCause() == null ? "" : response.getCause().getMessage())); Set tools = ShellToolsAgentCmp.PROBE.get().toolNames(); - Assertions.assertTrue(tools.contains("execute_shell_command")); + Assertions.assertTrue(tools.contains("execute_shell_command"), + "Shell mode=WHITELIST 时应注册 execute_shell_command"); - // pwd 在白名单中,应执行成功。 + // pwd 在白名单中,工具执行体应执行成功。 String pwd = ShellToolsAgentCmp.PWD_OUTPUT.get(); - Assertions.assertNotNull(pwd); + Assertions.assertNotNull(pwd, "WHITELIST 模式应允许白名单内的 pwd 执行"); Assertions.assertEquals(ShellToolsAgentCmp.WORKSPACE.get(), pwd.trim()); - - // rm 不在白名单中,应被拒绝。 - Assertions.assertTrue(ShellToolsAgentCmp.BLOCKED_OUTPUT.get().contains("not allowed by whitelist")); } } diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/shelltool/ShellToolsAgentCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/shelltool/ShellToolsAgentCmp.java index 668906fb4..5d4a23b2e 100644 --- a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/shelltool/ShellToolsAgentCmp.java +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/shelltool/ShellToolsAgentCmp.java @@ -2,35 +2,41 @@ package com.yomahub.liteflow.test.agent.feature.shelltool; import com.yomahub.liteflow.agent.component.ReActAgentComponent; import com.yomahub.liteflow.agent.model.ModelSpec; +import com.yomahub.liteflow.agent.tool.ManagedShellCommandTool; +import com.yomahub.liteflow.property.agent.ShellMode; import com.yomahub.liteflow.test.agent.support.LiveTestSupport; import io.agentscope.core.hook.Hook; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.tool.ToolCallParam; import org.springframework.stereotype.Component; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; /** - * 开启 Shell 工具的 Agent,在 userPrompt 中直接调用受管 Shell 工具 - * 验证 DISABLED / BLACKLIST / WHITELIST 三种模式下的工具行为。 + * 开启 Shell 工具的 Agent,在 userPrompt 中直接调用受管 Shell 工具验证其执行体 + * (ProcessBuilder + timeout + maxOutputBytes),并记录 workspace 路径。 * - *

v2 迁移期占位(Task 2.2c):原 1.0 的 {@code ManagedShellCommandTool} - * 已在 Task 0 删除,待 Task 3.2 在 RC3-core 下重建自建 Shell 工具后恢复本 userPrompt 的 - * 直接调用逻辑。当前 {@code userPrompt()} 仅返回 chain requestData,{@code PWD_OUTPUT} / - * {@code BLOCKED_OUTPUT} 字段保留以维持测试主体 {@link ShellToolModesFeatureTest} 编译, - * 但其断言在 Task 3.2 重建前无意义(测试整体也会因 {@code process()} stub 失败/跳过)。 + *

v2(RC3)状态(Task 3.2 un-stub):{@code ManagedShellCommandTool} 已从 1.0 改写为 + * {@code ToolBase} 子类(覆写 {@code matchRule} 接 v2 PermissionEngine)。命令级白/黑名单 + * 裁决已移到 PermissionEngine(Task 3.1 {@code PermissionConfigMapper} 产命令级规则), + * 由独立的 {@code ShellPermissionBehaviorTest} 验证。本组件只验证工具执行体: + * 直接 {@code callAsync} 一个无害命令({@code pwd})证明 ProcessBuilder 链路在 mode 允许时可用。 + * + *

不再在 userPrompt 里跑危险命令(1.0 的 {@code rm -rf /} 示例)——拒绝路径由 + * PermissionEngine 在工具调用前 gate,不归本组件验证(且直接 {@code callAsync} 绕过引擎,会真的执行)。 */ @Component("shellToolsAgent") public class ShellToolsAgentCmp extends ReActAgentComponent { public static final AtomicReference PROBE = new AtomicReference<>(); public static final AtomicReference PWD_OUTPUT = new AtomicReference<>(); - public static final AtomicReference BLOCKED_OUTPUT = new AtomicReference<>(); public static final AtomicReference WORKSPACE = new AtomicReference<>(); public static void reset() { PROBE.set(new AgentProbe()); PWD_OUTPUT.set(null); - BLOCKED_OUTPUT.set(null); WORKSPACE.set(null); } @@ -72,12 +78,24 @@ public class ShellToolsAgentCmp extends ReActAgentComponent { @Override protected String userPrompt() { - // TODO(Task 3.2): 1.0 ManagedShellCommandTool 已删除,待 RC3-core 下重建自建 Shell 工具后 - // 恢复以下直接调用逻辑(PWD_OUTPUT / BLOCKED_OUTPUT / WORKSPACE 的真实填充): - // ManagedShellCommandTool tool = new ManagedShellCommandTool(ctx().getWorkspaceDir(), agentConfig()); - // WORKSPACE.set(ctx().getWorkspaceDir().toAbsolutePath().normalize().toString()); - // PWD_OUTPUT.set(tool.executeCommand("pwd")); - // BLOCKED_OUTPUT.set(tool.executeCommand("rm -rf /")); + // 记录本次 workspace(与 agent 单例绑定的 cfg 根目录不同——此处用 ctx 的 per-session 目录, + // 与 ManagedShellCommandTool 实际绑定目录在 RC3 下一致)。 + WORKSPACE.set(ctx().getWorkspaceDir().toAbsolutePath().normalize().toString()); + + // 直接 callAsync 一个无害命令(pwd)验证工具执行体(ProcessBuilder 链路)。 + // 注意:直接 callAsync 绕过 PermissionEngine,故只用无害命令;命令级权限裁决由 + // ShellPermissionBehaviorTest 在引擎层验证。DISABLED 模式下 ReactAgentFactory 不注册 + // 工具(模型看不到 schema),此处也不再直接执行以保持语义一致。 + ShellMode mode = agentConfig().getShell().getMode(); + if (mode != ShellMode.DISABLED) { + ManagedShellCommandTool tool = new ManagedShellCommandTool(ctx().getWorkspaceDir(), agentConfig()); + ToolCallParam param = ToolCallParam.builder() + .input(Map.of("command", "pwd")) + .build(); + String out = ((TextBlock) tool.callAsync(param).block().getOutput().get(0)).getText(); + PWD_OUTPUT.set(out); + } + Object reqData = getSlot().getChainReqData(getSlot().getChainId()); return reqData == null ? "" : reqData.toString(); } diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/workspacetools/WorkspaceToolsAgentCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/workspacetools/WorkspaceToolsAgentCmp.java index e522976a9..0922d912f 100644 --- a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/workspacetools/WorkspaceToolsAgentCmp.java +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/workspacetools/WorkspaceToolsAgentCmp.java @@ -2,6 +2,7 @@ package com.yomahub.liteflow.test.agent.feature.workspacetools; import com.yomahub.liteflow.agent.component.ReActAgentComponent; import com.yomahub.liteflow.agent.model.ModelSpec; +import com.yomahub.liteflow.agent.tool.WorkspaceFileTools; import com.yomahub.liteflow.test.agent.support.LiveTestSupport; import io.agentscope.core.hook.Hook; import org.springframework.stereotype.Component; @@ -13,12 +14,11 @@ import java.util.concurrent.atomic.AtomicReference; * 开启 workspace 文件工具的 Agent,并在 userPrompt 中直接调用受管 workspace 文件工具 * 验证其行为(read/write/list/delete/path-escape),使断言不依赖模型是否真的调用工具。 * - *

v2 迁移期占位(Task 2.2c):原 1.0 的 {@code WorkspaceFileTools} - * 已在 Task 0 删除,待 Task 3.2 在 RC3-core 下重建自建文件系统工具后恢复本 userPrompt 的 - * 直接调用逻辑。当前 {@code userPrompt()} 仅返回 chain requestData,{@code TRUNCATED_READ} / - * {@code LIST_RESULT} / {@code DELETED} / {@code RELATIVE_ESCAPE} / {@code ABSOLUTE_ESCAPE} - * 字段保留以维持测试主体 {@link WorkspaceToolsFeatureTest} 编译,但其断言在 Task 3.2 - * 重建前无意义(测试整体也会因 {@code process()} stub 失败/跳过)。 + *

v2(RC3)状态(Task 3.2 un-stub):{@code WorkspaceFileTools} 已从 1.0 原样恢复 + * (注解式,{@code Toolkit.registerTool(Object)} 注册)。本组件的 {@code userPrompt} 直接 + * 实例化一份工具并调用其注解方法——这是白盒断言,与 agent 是否真的触发工具无关。 + * {@code ctx().getWorkspaceDir()} 提供 per-(conversationId) 工作目录(与 agent 单例绑定的 + * cfg 根目录不同,此处独立用 ctx 的目录以验证目录隔离语义)。 */ @Component("workspaceToolsAgent") public class WorkspaceToolsAgentCmp extends ReActAgentComponent { @@ -78,21 +78,26 @@ public class WorkspaceToolsAgentCmp extends ReActAgentComponent { @Override protected String userPrompt() { - // TODO(Task 3.2): 1.0 WorkspaceFileTools 已删除,待 RC3-core 下重建自建文件系统工具后 - // 恢复以下直接调用逻辑(TRUNCATED_READ / LIST_RESULT / DELETED / RELATIVE_ESCAPE / - // ABSOLUTE_ESCAPE 的真实填充): - // WorkspaceFileTools tools = new WorkspaceFileTools(ctx().getWorkspaceDir(), agentConfig()); - // tools.writeFile("notes/a.txt", "abcdef"); - // tools.writeFile("notes/b.txt", "ghijkl"); - // TRUNCATED_READ.set(tools.readFile("notes/a.txt")); - // LIST_RESULT.set(tools.listFiles("notes")); - // tools.deleteFile("notes/b.txt"); - // DELETED.set(!java.nio.file.Files.exists(ctx().getWorkspaceDir().resolve("notes/b.txt"))); - // try { tools.readFile("../escape.txt"); } - // catch (SecurityException e) { RELATIVE_ESCAPE.set(e.getMessage()); } - // try { tools.readFile("/tmp/escape.txt"); } - // catch (SecurityException e) { ABSOLUTE_ESCAPE.set(e.getMessage()); } + // 白盒断言:直接 new 一份工具调用其注解方法,与模型是否真的触发工具无关。 + WorkspaceFileTools tools = new WorkspaceFileTools(ctx().getWorkspaceDir(), agentConfig()); + tools.writeFile("notes/a.txt", "abcdef"); + tools.writeFile("notes/b.txt", "ghijkl"); + TRUNCATED_READ.set(tools.readFile("notes/a.txt")); + LIST_RESULT.set(tools.listFiles("notes")); + tools.deleteFile("notes/b.txt"); + DELETED.set(!java.nio.file.Files.exists(ctx().getWorkspaceDir().resolve("notes/b.txt"))); + try { + tools.readFile("../escape.txt"); + } catch (SecurityException e) { + RELATIVE_ESCAPE.set(e.getMessage()); + } + try { + tools.readFile("/tmp/escape.txt"); + } catch (SecurityException e) { + ABSOLUTE_ESCAPE.set(e.getMessage()); + } Object reqData = getSlot().getChainReqData(getSlot().getChainId()); return reqData == null ? "" : reqData.toString(); } } + diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ShellPermissionBehaviorTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ShellPermissionBehaviorTest.java new file mode 100644 index 000000000..b4ba8cda6 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ShellPermissionBehaviorTest.java @@ -0,0 +1,113 @@ +package com.yomahub.liteflow.test.agent.v2; + +import com.yomahub.liteflow.agent.permission.PermissionConfigMapper; +import com.yomahub.liteflow.agent.tool.ManagedShellCommandTool; +import com.yomahub.liteflow.property.agent.AgentConfig; +import com.yomahub.liteflow.property.agent.ShellConfig; +import com.yomahub.liteflow.property.agent.ShellMode; +import io.agentscope.core.permission.PermissionBehavior; +import io.agentscope.core.permission.PermissionContextState; +import io.agentscope.core.permission.PermissionDecision; +import io.agentscope.core.permission.PermissionEngine; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Task 3.2 端到端行为测试(Task 3.1 review 的 M2 follow-up):验证 v2 + * {@link PermissionEngine} 经 {@link PermissionConfigMapper} 产出的命令级规则, + * 配合 {@link ManagedShellCommandTool#matchRule(String, java.util.Map)}(首 token equals), + * 对 {@code ls} / {@code rm} 在三种 {@link ShellMode} 下做出正确裁决。 + * + *

不构建 ReActAgent、不调 LLM、不起 Spring、不真跑命令——直接构造 + * {@link PermissionEngine}({@code PermissionConfigMapper.map(cfg)} 为入参)+ + * {@link ManagedShellCommandTool} 实例,调 {@code engine.checkPermission(tool, input).block()} + * 断言 {@link PermissionDecision#getBehavior()}。工具的 {@code callAsync} 永不被触发 + * (裁决在前;DENY/ASK 时引擎短路)。 + * + *

预期裁决矩阵(findings R6-ext): + * + * + * + * + * + * + * + * + *
modecommandbehavior
WHITELIST=[ls] (DONT_ASK)lsALLOW
WHITELIST=[ls]rmDENY(非白名单经 default + DONT_ASK 转 DENY)
BLACKLIST=[rm] (DEFAULT)rmDENY
BLACKLIST=[rm]lsALLOW(catch-all allow 兜底)
DISABLED (DEFAULT)lsDENY(catch-all deny)
DISABLEDrmDENY
+ * + *

这一关也顺带验证了 {@code ManagedShellCommandTool.matchRule} 的首-token-equals 语义: + * 若 matchRule 未覆写(用基类默认 {@code ruleContent==null}),per-token allow/deny 规则 + * 永不命中,本测试会全红——故该测试是 matchRule 覆写正确性的直接门控。 + */ +class ShellPermissionBehaviorTest { + + @TempDir + Path workspace; + + @Test + void whitelistMode_allowsWhitelistedCommand_deniesOthers() { + AgentConfig cfg = shellCfg(ShellMode.WHITELIST, Collections.singletonList("ls"), Collections.emptyList()); + PermissionEngine engine = new PermissionEngine(PermissionConfigMapper.map(cfg)); + ManagedShellCommandTool tool = new ManagedShellCommandTool(workspace, cfg); + + // ls 在白名单 → ALLOW + PermissionDecision ls = engine.checkPermission(tool, Map.of("command", "ls -la")).block(); + assertEquals(PermissionBehavior.ALLOW, ls.getBehavior(), + "WHITELIST 模式下白名单内的 ls 应被 ALLOW"); + + // rm 不在白名单 → 非 allow 命中 → 经 default path + DONT_ASK 转 DENY + PermissionDecision rm = engine.checkPermission(tool, Map.of("command", "rm -rf /")).block(); + assertEquals(PermissionBehavior.DENY, rm.getBehavior(), + "WHITELIST 模式下非白名单的 rm 应被 DENY(DONT_ASK default 转 DENY)"); + } + + @Test + void blacklistMode_deniesBlacklistedCommand_allowsOthers() { + AgentConfig cfg = shellCfg(ShellMode.BLACKLIST, Collections.emptyList(), Collections.singletonList("rm")); + PermissionEngine engine = new PermissionEngine(PermissionConfigMapper.map(cfg)); + ManagedShellCommandTool tool = new ManagedShellCommandTool(workspace, cfg); + + // rm 在黑名单 → DENY(deny 先于 allow 评估) + PermissionDecision rm = engine.checkPermission(tool, Map.of("command", "rm -rf /")).block(); + assertEquals(PermissionBehavior.DENY, rm.getBehavior(), + "BLACKLIST 模式下黑名单内的 rm 应被 DENY"); + + // ls 不在黑名单 → catch-all allow 兜底放行 + PermissionDecision ls = engine.checkPermission(tool, Map.of("command", "ls -la")).block(); + assertEquals(PermissionBehavior.ALLOW, ls.getBehavior(), + "BLACKLIST 模式下非黑名单的 ls 应被 ALLOW(catch-all allow 兜底)"); + } + + @Test + void disabledMode_deniesAllCommands() { + AgentConfig cfg = shellCfg(ShellMode.DISABLED, Collections.emptyList(), Collections.emptyList()); + PermissionEngine engine = new PermissionEngine(PermissionConfigMapper.map(cfg)); + ManagedShellCommandTool tool = new ManagedShellCommandTool(workspace, cfg); + + // catch-all deny → 所有命令一律 DENY + PermissionDecision ls = engine.checkPermission(tool, Map.of("command", "ls -la")).block(); + assertEquals(PermissionBehavior.DENY, ls.getBehavior(), + "DISABLED 模式下 ls 应被 DENY(整工具禁用)"); + + PermissionDecision rm = engine.checkPermission(tool, Map.of("command", "rm -rf /")).block(); + assertEquals(PermissionBehavior.DENY, rm.getBehavior(), + "DISABLED 模式下 rm 应被 DENY(整工具禁用)"); + } + + /* ----- helpers ----- */ + + private static AgentConfig shellCfg(ShellMode mode, java.util.List whitelist, java.util.List blacklist) { + AgentConfig cfg = new AgentConfig(); + ShellConfig shell = cfg.getShell(); + shell.setMode(mode); + shell.setWhitelist(whitelist); + shell.setBlacklist(blacklist); + return cfg; + } +}