mirror of https://gitee.com/dromara/liteFlow
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:
parent
39d20d38f9
commit
85f679ad4a
|
|
@ -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<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(...))`。
|
||||
|
|
|
|||
|
|
@ -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 工具实例)。
|
||||
*
|
||||
* <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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>v2(RC3)状态:</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>matchRule(findings 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 equals(findings 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>v2(RC3)状态:</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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>v2(RC3)状态(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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>v2(RC3)状态(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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>v2(RC3)状态(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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>ALLOW(catch-all allow 兜底)</td></tr>
|
||||
* <tr><td>DISABLED (DEFAULT)</td><td>ls</td><td>DENY(catch-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 应被 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<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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue