feat(agent): 恢复自建文件/shell 工具(ManagedShellCommandTool 为 ToolBase+matchRule 接 PermissionEngine;WorkspaceFileTools 注解式)

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 <noreply@anthropic.com>
This commit is contained in:
everywhere.z 2026-06-20 10:21:57 +08:00
parent 39d20d38f9
commit 85f679ad4a
8 changed files with 608 additions and 54 deletions

View File

@ -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-ext2ToolBase 子类定义 + 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<String,Object> inputSchema, boolean readOnly, boolean concurrencySafe, boolean mcp, String mcpName, boolean externalTool, boolean stateInjected)`。
`ToolBase.Builder` 全量方法(实测):`name, description, inputSchema(Map<String,Object>), 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<ToolResultBlock>`)。
子类必须覆写 `callAsync`(否则基类抛 `UnsupportedOperationException`,除非 `externalTool=true`)。基类 `callAsync` 签名:
```java
@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) // 非 final可覆写
```
`inputSchema` 是 JSON-Schema 形态的 `Map<String,Object>`(如 `{"type":"object","properties":{...},"required":[...]}`),由子类自构造(基类不生成 schema
### (b) `matchRule(String content, Map<String,Object> input)` 精确签名
```java
public boolean matchRule(String ruleContent, Map<String, Object> toolInput)
// 默认实现return ruleContent == null;
// 非 final子类可覆写。
```
- 第一参数 `ruleContent`:来自 `PermissionRule.ruleContent()`(裸字符串,由 `PermissionConfigMapper` 产出,约定为首 token 如 `"ls"`catch-all 传 `null`)。
- 第二参数 `toolInput`:即工具收到的 `toolInput``PermissionEngine.checkPermission(ToolBase, Map<String,Object>)` 传入,字段名 = 工具参数名)。对 `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<String,Object>`unmodifiable即工具参数`getToolUseBlock()` 含 call id/name`getAgent()`/`getRuntimeContext()` 可空。子类 `callAsync` 实现典型:从 `param.getInput().get("command")` 取参、执行、返回 `Mono.just(new ToolResultBlock(...))`

View File

@ -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_commandToolBase 子类覆写 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 工具实例
*
* <p> {@link ReActAgentComponent#workspaceRoot(AgentConfig, String)} 同一套回退规则
* 但不带 {@code conversationId}agent 是按 cmp 子类缓存的单例工具实例随之跨调用
* 复用故绑定到 cfg 配置的<b></b>目录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);
}
}

View File

@ -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} 执行单条命令拒绝管道 / 重定向 / 命令链并对执行时长与输出体积限流
*
* <p><b>v2RC3状态</b>Task 3.2 1.0 改写为 {@link ToolBase} 子类非注解式
* 1.0 在工具内手写的<b> / 黑名单 / DISABLED 裁决已移除</b>改由 v2
* {@code PermissionEngine} 在工具调用前裁决Task 3.1 {@link PermissionConfigMapper}
* {@link ShellConfig} 映射为命令级 {@code PermissionContextState} 规则本工具只保留
* <ul>
* <li>拒绝管道 / 重定向 / 命令链{@code | < > && || ;}1.0 安全逻辑</li>
* <li>{@code timeout}到时强杀子进程</li>
* <li>{@code maxOutputBytes}截断 stdout防止 LLM 上下文被巨量输出塞满</li>
* </ul>
*
* <h2>matchRulefindings R6-ext / R6-ext2</h2>
* 本类覆写 {@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
*
* <p><b>为什么必须是 {@code ToolBase} 子类而非注解式</b>注解式经
* {@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 equalsfindings R6-ext / R6-ext2===== */
/**
* 命令级规则匹配{@code ruleContent} 与命令首 token 精确比对
*
* <p>{@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<String, Object> 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<ToolResultBlock> 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 判断<b>已移除</b>交由 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<String> 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<byte[]> 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<String, Object> commandSchema() {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
Map<String, Object> properties = new LinkedHashMap<>();
Map<String, Object> 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;
}
}

View File

@ -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 ..} 逃逸一律拒绝
*
* <p><b>v2RC3状态</b>本类为 Task 3.2 1.0 原样恢复的注解式工具v2
* {@code Toolkit.registerTool(Object)} 仍扫描 {@link Tool}/{@link ToolParam} 注解方法
* 逐个经 {@code ReflectiveFunctionTool} 包成 {@code ToolBase} 子类注册注解式工具
* {@code ToolBase} <b>默认</b> {@code matchRule}{@code ruleContent == null} 才匹配
* 故只受 catch-all 权限规则约束本类无 per-token 命令语义无需覆写 {@code matchRule}
* 命令级权限门控仅对 {@code ManagedShellCommandTool}同为 Task 3.2 恢复ToolBase 子类
* 有意义
*
* <p>限流字段{@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<String> listFiles(
@ToolParam(name = "path", required = false, description = "Relative path; defaults to current dir") String path) {
Path dir = resolveSafe(path == null || path.isEmpty() ? "." : path);
List<String> 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;
}
}

View File

@ -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
*
* <p><b>v2RC3状态Task 3.2 更新</b>命令级<b>/黑名单裁决已从工具内移到
* PermissionEngine</b>Task 3.1 {@code PermissionConfigMapper}本功能测试只验证
* <ul>
* <li>工具注册受 mode 门控DISABLED 不注册BLACKLIST/WHITELIST 注册PROBE toolkit</li>
* <li>工具<b>执行体</b>在允许场景下可用{@code pwd} workspace 下执行</li>
* </ul>
* 命令级拒绝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<String> 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"));
}
}

View File

@ -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 路径
*
* <p><b>v2 迁移期占位Task 2.2c</b> 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 失败/跳过
* <p><b>v2RC3状态Task 3.2 un-stub</b>{@code ManagedShellCommandTool} 已从 1.0 改写为
* {@code ToolBase} 子类覆写 {@code matchRule} v2 PermissionEngine命令级<b>/黑名单
* 裁决已移到 PermissionEngine</b>Task 3.1 {@code PermissionConfigMapper} 产命令级规则
* 由独立的 {@code ShellPermissionBehaviorTest} 验证本组件只验证工具<b>执行体</b>
* 直接 {@code callAsync} 一个无害命令{@code pwd}证明 ProcessBuilder 链路在 mode 允许时可用
*
* <p><b>不再在 userPrompt 里跑危险命令</b>1.0 {@code rm -rf /} 示例拒绝路径由
* PermissionEngine 在工具调用前 gate不归本组件验证且直接 {@code callAsync} 绕过引擎会真的执行
*/
@Component("shellToolsAgent")
public class ShellToolsAgentCmp extends ReActAgentComponent {
public static final AtomicReference<AgentProbe> PROBE = new AtomicReference<>();
public static final AtomicReference<String> PWD_OUTPUT = new AtomicReference<>();
public static final AtomicReference<String> BLOCKED_OUTPUT = new AtomicReference<>();
public static final AtomicReference<String> 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();
}

View File

@ -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使断言不依赖模型是否真的调用工具
*
* <p><b>v2 迁移期占位Task 2.2c</b> 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 失败/跳过
* <p><b>v2RC3状态Task 3.2 un-stub</b>{@code WorkspaceFileTools} 已从 1.0 原样恢复
* 注解式{@code Toolkit.registerTool(Object)} 注册本组件的 {@code userPrompt} 直接
* 实例化一份工具并调用其注解方法这是<b>白盒断言</b> 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();
}
}

View File

@ -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} 下做出正确裁决
*
* <p><b>不构建 ReActAgent不调 LLM不起 Spring不真跑命令</b>直接构造
* {@link PermissionEngine}{@code PermissionConfigMapper.map(cfg)} 为入参+
* {@link ManagedShellCommandTool} 实例 {@code engine.checkPermission(tool, input).block()}
* 断言 {@link PermissionDecision#getBehavior()}工具的 {@code callAsync} 永不被触发
* 裁决在前DENY/ASK 时引擎短路
*
* <p>预期裁决矩阵findings R6-ext
* <table>
* <tr><th>mode</th><th>command</th><th>behavior</th></tr>
* <tr><td>WHITELIST=[ls] (DONT_ASK)</td><td>ls</td><td>ALLOW</td></tr>
* <tr><td>WHITELIST=[ls]</td><td>rm</td><td>DENY非白名单经 default + DONT_ASK DENY</td></tr>
* <tr><td>BLACKLIST=[rm] (DEFAULT)</td><td>rm</td><td>DENY</td></tr>
* <tr><td>BLACKLIST=[rm]</td><td>ls</td><td>ALLOWcatch-all allow 兜底</td></tr>
* <tr><td>DISABLED (DEFAULT)</td><td>ls</td><td>DENYcatch-all deny</td></tr>
* <tr><td>DISABLED</td><td>rm</td><td>DENY</td></tr>
* </table>
*
* <p>这一关也顺带验证了 {@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 应被 DENYDONT_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 在黑名单 DENYdeny 先于 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 应被 ALLOWcatch-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<String> whitelist, java.util.List<String> blacklist) {
AgentConfig cfg = new AgentConfig();
ShellConfig shell = cfg.getShell();
shell.setMode(mode);
shell.setWhitelist(whitelist);
shell.setBlacklist(blacklist);
return cfg;
}
}