feat(agent): PermissionConfigMapper 把 ShellConfig 映射到 v2 PermissionContextState(命令级规则)

Task 3.1:把 liteflow ShellConfig(mode/whitelist/blacklist)翻译成 v2
PermissionContextState 的命令级规则,替代 1.0 在 ManagedShellCommandTool 内
手写的命令过滤。执行期由 v2 PermissionEngine 在工具调用前裁决。

探针(findings R6-ext,直接读 RC3 源码):
- PermissionEngine 不解析 ruleContent;null/空=catch-all,非空完全委派
  tool.matchRule(content, input)。RC3 core 无任何 ToolBase 子类覆写
  matchRule,故 matcher DSL 由 Task 3.2 的自建工具定义。
- 默认裁决顺序:denyRules→askRules→工具自检→allowRules→BYPASS→default
  (DEFAULT=ASK,DONT_ASK=DENY)。有 allowRules 不隐含其余 deny。

ruleContent 约定(Task 3.1 + Task 3.2 共同):
- 精确匹配首 token = 裸字符串(如 "ls")
- catch-all = null

各 mode 映射:
- DISABLED → DEFAULT + catch-all deny
- WHITELIST → DONT_ASK + 每条白名单 allow(非白名单经 default 转 DENY;
  不能用 catch-all deny——denyRules 先于 allowRules 会吞掉白名单)
- BLACKLIST → DEFAULT + 每条黑名单 deny + catch-all allow 兜底

ReactAgentFactory.build 加 .permissionContext(PermissionConfigMapper.map(cfg))。
map() 永非 null(null/缺省/未识别枚举回退安全默认)。

校验:core compile SUCCESS;test-compile SUCCESS;
PermissionConfigMapperTest 5/5(javac+JUnit Launcher,sibling 测试致模块
mvn test 不能跑,沿用 V2ApiProbe 手法);ProcessIntegrationTest 1/1
(permission 不破坏非 shell 调用)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
everywhere.z 2026-06-20 09:59:03 +08:00
parent 01cd0ddc32
commit 39d20d38f9
4 changed files with 377 additions and 1 deletions

View File

@ -228,6 +228,55 @@ PermissionContextState.builder()
---
## R6-extmatcher DSL 精确语义 + 默认裁决Task 3.1 探针实测)
**来源:** 对 `agentscope-2.0.0-RC3-sources.jar``io/agentscope/core/permission/PermissionEngine.java`、`PermissionContextState.java`、`PermissionRule.java` 与 `io/agentscope/core/tool/ToolBase.java` 全量源码通读JDK 21。**非反射探针——直接读 RC3 源码,比反射更权威。**
**核心修正(推翻 R6 "由 PermissionEngine 实现 OpenAI 风格 DSL" 的推测):**
1. **`PermissionEngine` 不解析 `ruleContent`。** 全 jar 唯一读 `rule.ruleContent()` 的地方是 `PermissionEngine.ruleMatches(tool, rule, input)`
```java
private boolean ruleMatches(ToolBase tool, PermissionRule rule, Map<String,Object> input) {
String content = rule.ruleContent();
if (content == null || content.isEmpty()) {
return true; // ← catch-allnull/空 content 匹配一切
}
return tool.matchRule(content, input); // ← 否则完全委托给工具自己解析
}
```
**matcher DSL 不是 agentscope 内置,而是各工具(`ToolBase` 子类)自己实现 `matchRule(content, input)` 时定义。**
2. **RC3 core 里没有任何 `ToolBase` 子类覆写 `matchRule`。** `ToolBase` 的实现是 `return ruleContent == null;`(只认 catch-all。三个 `extends ToolBase` 的类(`SchemaOnlyTool`/`ReflectiveFunctionTool`/`McpTool`)均未覆写 `matchRule`。而 `io.agentscope.core.tool.coding.ShellCommandTool` 实现的是 `AgentTool`(非 `ToolBase`**根本不参与 `PermissionEngine` 裁决**(只有 `ToolBase` 子类经 `Toolkit` 注册后才会被引擎 gate。全 jar `grep matchRule` 命中:仅 `PermissionEngine`、`PermissionRule`、`ToolBase` 三处。
**结论R6 里举例的 `"command =~ '^ls .*'"` / `"command IN [...]"` / `"command == 'rm'"` 等 DSL 字符串RC3 里没有任何代码能解析**——它们只是 R6 文档的示意,不是可用语法。
3. **默认裁决顺序(`PermissionEngine.checkPermission`,实测源码):**
denyRules最高→ askRules → 工具自检(`ToolBase.checkPermissions`,默认 passthroughEXPLORE/ACCEPT_EDITS read-only 在此)→ allowRules → BYPASS 模式放行 → **default ASKDONT_ASK 模式下转 DENY**
- **关键:有 allowRules 并不隐含"其余 deny"**——未命中任何规则的调用落到 default ASK**不是** deny。要实现"白名单才允许、其余拒绝"语义,**必须显式加一条 catch-all DENY 规则**`ruleContent = null`)兜底,否则非白名单命令只会触发 ASK或 DONT_ASK 下的 DENY不是稳定的 DENY。
4. **catch-all 规则** = `new PermissionRule(toolName, null, behavior, source)``ruleContent` 传 `null`)。`PermissionRule` 紧凑构造器只对 `toolName`/`behavior`/`source` 做 `Objects.requireNonNull`**`ruleContent` 允许 null**,引擎见 null 即匹配一切。
5. **输入字段名**matcher 第二参数 `Map<String,Object> input` 即工具收到的 `toolInput`。字段名由工具的 `@Tool`/`@ToolParam` 参数名决定——对自建 `ManagedShellCommandTool` 而言就是其参数名(约定 `command`)。
**对 Task 3.1PermissionConfigMapper的最终指导已采用**
由于 matcher DSL 完全由自建工具定义Task 3.1 与 Task 3.2(恢复 `ManagedShellCommandTool`**共同约定**一套简单、易解析的 `ruleContent` 格式:
- **每条命令一行精确匹配**`ruleContent = "<firstToken>"`(即裸 token 字符串,如 `"ls"`、`"rm"`。Task 3.2 的 `ManagedShellCommandTool.matchRule(content, input)` 实现为:取 `input.get("command")` 的首 token`content` 字符串 `equals` 比对。这比 R6 示例的 `command == 'ls'` DSL 更简单、无需引号/转义解析器,且天然表达"首 token 等于 X"语义。
- **catch-all**`ruleContent = null`(引擎原生支持)。
`ShellMode` 的规则构造mapper 行为):
- **DISABLED**:单条 `addDenyRule("execute_shell_command", new PermissionRule(TOOL, null, DENY, "shell-config"))` —— catch-all deny禁用整个工具。
- **WHITELIST**(默认):每条白名单命令一条 `addAllowRule(TOOL, new PermissionRule(TOOL, "<token>", ALLOW, "shell-config"))`**外加一条** `addDenyRule(TOOL, new PermissionRule(TOOL, null, DENY, "shell-config"))` 兜底,使非白名单命令稳定 DENY而非落到 default ASK。deny 优先级高于 allow但白名单 allow 与 catch-all deny 不会冲突catch-all 只匹配"未被前面 allow 规则认领"的调用(实际引擎是逐条 evaluatedenyRules 先扫——见下注意)。
- **⚠️ 顺序注意**:引擎先评估 denyRules 再评估 allowRules。若 catch-all deny 放在 WHITELIST它会在 allowRules 之前命中**所有**命令(包括白名单内的),导致白名单也被 deny。**因此 WHITELIST 模式不能用 catch-all deny 实现兜底**——正确做法:**只加白名单 allow 规则mode 设为 `DONT_ASK`**,使未命中白名单的调用经 default path 转 DENYDONT_ASK 模式 defaultDecisionAsk 返回 DENY。这是 RC3 下表达"白名单"语义的唯一正确方式。
- **BLACKLIST**:每条黑名单命令一条 `addDenyRule(TOOL, new PermissionRule(TOOL, "<token>", DENY, "shell-config"))`**外加一条** `addAllowRule(TOOL, new PermissionRule(TOOL, null, ALLOW, "shell-config"))` 兜底放行。因引擎 denyRules 先于 allowRules 评估,黑名单命令会被前面的 deny 命中拒绝,其余命令落到后面 catch-all allow 被放行——干净实现"黑名单拒绝、其余允许",不依赖 mode 副作用mode 保持 DEFAULT 即可)。**不要**用 BYPASS 模式实现BYPASS 对所有工具全放行、且会绕过规则评估,粒度太粗)。
**最终采用(见 PermissionConfigMapper 实现):**
- DISABLED → mode DEFAULT + 单条 catch-all deny 规则toolName 级),禁用整个 `execute_shell_command` 工具。
- WHITELIST → mode **DONT_ASK** + 每条白名单 allow 规则(无 catch-all deny否则会先于 allow 吞掉白名单)。非白名单命令经 default path + DONT_ASK 转 DENY白名单命令命中 allow 放行。
- BLACKLIST → mode DEFAULT + 每条黑名单 deny 规则 + 一条 catch-all allow 兜底deny 先于 allow 评估,黑名单硬拒、其余放行)。
---
## R7JDK 21 baseline
**结论(实测):** agentscope core 2.0.0-RC3 + 所声明的 4 个扩展redis/mysql/oss/skill-git-repository**JDK 21.0.2** 下全部解析成功;`liteflow-react-agent-core` 干净编译(`mvn -pl .../liteflow-react-agent-core -am clean compile` → **BUILD SUCCESS**`V2ApiProbe` 6/6 测试绿。baseline 成立。

View File

@ -1,10 +1,12 @@
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.property.agent.AgentConfig;
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;
@ -44,11 +46,12 @@ import java.util.concurrent.ConcurrentHashMap;
* {@code registerTool(Object)}v2 反射注册 {@code @Tool} 方法</li>
* <li>{@code maxIters} {@code cmp.maxIterations() > 0 ? it : cfg.defaults.maxIterations}</li>
* <li>{@code stateStore} {@link AgentStateStoreResolver#resolve(AgentConfig)}可能返回 null=NONE合法</li>
* <li>{@code permissionContext} {@link PermissionConfigMapper#map(AgentConfig)}ShellConfigv2 命令级规则Task 3.1</li>
* </ul>
*
* <p><b>不设留给后续 Task / RC3 </b>
* {@code .filesystem()/.workspace()/.compaction()/.memory(MemoryConfig)}RC3 HarnessAgent 这些方法
* {@code .middleware(...)Task 5.1.permissionContext(...)Task 3.1
* {@code .middleware(...)Task 5.1
* {@code .skillRepository(...)Task 4.1本类不引用这些协作者
*
* <h2>线程安全</h2>
@ -119,6 +122,10 @@ public final class ReactAgentFactory {
}
AgentStateStore stateStore = AgentStateStoreResolver.resolve(cfg);
// ShellConfig v2 PermissionContextState 命令级规则findings R6-ext永非 null
// 在工具调用前由 v2 PermissionEngine 裁决 execute_shell_commandTask 3.2 恢复
// ManagedShellCommandTool 该工具的 matchRule 即按本 mapper 产出的 ruleContent 约定解析
PermissionContextState permissionContext = PermissionConfigMapper.map(cfg);
try {
return ReActAgent.builder()
@ -128,6 +135,7 @@ public final class ReactAgentFactory {
.toolkit(toolkit)
.maxIters(maxIters)
.stateStore(stateStore) // null 合法 = NONE 语义findings R1
.permissionContext(permissionContext)
.build();
} catch (Exception e) {
throw new AgentConfigException(

View File

@ -0,0 +1,161 @@
package com.yomahub.liteflow.agent.permission;
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.PermissionMode;
import io.agentscope.core.permission.PermissionRule;
import java.util.List;
/**
* liteflow {@link ShellConfig}mode/whitelist/blacklist翻译成 v2
* {@link PermissionContextState} 的命令级规则替代 1.0 {@code ManagedShellCommandTool}
* 内手写的命令过滤执行期由 v2 {@code PermissionEngine} 在工具调用前裁决
*
* <h2>工具名</h2>
* 所有规则 target {@link #SHELL_TOOL_NAME} Task 3.2 将恢复的
* {@code ManagedShellCommandTool} {@code @Tool(name=...)} 1.0 一致
*
* <h2>matcher DSLfindings R6-ext实测 RC3 源码</h2>
* {@code PermissionEngine} 不解析 {@code ruleContent}匹配完全委托给
* {@code ToolBase.matchRule(content, input)}RC3 core <b>没有</b>任何
* {@code ToolBase} 子类覆写 {@code matchRule}基类实现 {@code return ruleContent == null;}
* 只认 catch-all故本 mapper Task 3.2 {@code ManagedShellCommandTool.matchRule}
* 共同约定一套简单格式
* <ul>
* <li><b>精确匹配某命令首 token</b>{@code ruleContent = "<token>"}裸字符串 {@code "ls"}
* Task 3.2 的工具取 {@code input.get("command")} token 与之 {@code equals} 比对</li>
* <li><b>catch-all匹配一切</b>{@code ruleContent = null}引擎原生支持</li>
* </ul>
*
* <h2> mode 的规则构造</h2>
* 引擎评估顺序实测denyRules askRules 工具自检 allowRules BYPASS default
* DEFAULT 模式 default=ASKDONT_ASK 模式 default=DENY<b> allowRules 并不隐含"其余 deny"</b>
* <ul>
* <li><b>{@link ShellMode#DISABLED}</b>单条 catch-all deny{@code ruleContent=null}
* mode {@link PermissionMode#DEFAULT}整条工具禁用</li>
* <li><b>{@link ShellMode#WHITELIST}</b>默认每条白名单命令一条精确 allow 规则
* mode {@link PermissionMode#DONT_ASK}白名单命令命中 allow 放行非白名单不命中任何规则
* default path + DONT_ASK DENY注意<b>不能</b> catch-all deny 兜底denyRules
* 先于 allowRules 评估catch-all deny 会把白名单命令也一并吞掉DONT_ASK 是唯一干净表达
* "白名单才放行" RC3 语义</li>
* <li><b>{@link ShellMode#BLACKLIST}</b>每条黑名单命令一条精确 deny 规则 + 一条 catch-all
* allow 兜底mode {@link PermissionMode#DEFAULT}deny 先于 allow 评估黑名单命令被前面 deny
* 拒绝其余落到后面 catch-all allow 放行不依赖 mode 副作用不用 BYPASS它对所有工具全放行
* 且绕过规则评估粒度太粗</li>
* </ul>
*
* <h2>永非 null 契约</h2>
* {@link #map(AgentConfig)} 始终返回非 null {@link PermissionContextState}即便入参为 null
* shell 配置缺失也回退到一个安全默认DEFAULT mode 无规则调用方{@code ReactAgentFactory.build}
* 直接 {@code .permissionContext(map(cfg))} 即可
*/
public final class PermissionConfigMapper {
/**
* 受管 shell 工具名常量 Task 3.2 将恢复的 {@code ManagedShellCommandTool}
* {@code @Tool(name=...)} 一致沿用 1.0 命名
*/
public static final String SHELL_TOOL_NAME = "execute_shell_command";
/** 规则来源标签({@link PermissionRule#source()})。 */
private static final String SOURCE = "shell-config";
private PermissionConfigMapper() {
}
/**
* {@link AgentConfig#getShell()} 翻译成 {@link PermissionContextState}
*
* @param cfg agent 配置可为 null回退安全默认不抛
* @return 永远非 null {@link PermissionContextState}
*/
public static PermissionContextState map(AgentConfig cfg) {
if (cfg == null) {
return safeDefault();
}
ShellConfig shell = cfg.getShell();
if (shell == null) {
return safeDefault();
}
ShellMode mode = shell.getMode();
if (mode == null) {
mode = ShellMode.WHITELIST;
}
switch (mode) {
case DISABLED:
return disabledContext();
case WHITELIST:
return whitelistContext(shell.getWhitelist());
case BLACKLIST:
return blacklistContext(shell.getBlacklist());
default:
// 未来新增枚举值保守回退安全默认不抛不臆造映射
return safeDefault();
}
}
/* ----- 各 mode 的规则构造 ----- */
/** DISABLED单条 catch-all deny整条工具禁用。 */
private static PermissionContextState disabledContext() {
return PermissionContextState.builder()
.mode(PermissionMode.DEFAULT)
.addDenyRule(SHELL_TOOL_NAME, catchAll(PermissionBehavior.DENY))
.build();
}
/**
* WHITELIST每条白名单命令一条精确 allow + mode DONT_ASK非白名单经 default DENY
*/
private static PermissionContextState whitelistContext(List<String> whitelist) {
PermissionContextState.Builder b = PermissionContextState.builder()
.mode(PermissionMode.DONT_ASK);
if (whitelist != null) {
for (String cmd : whitelist) {
if (cmd != null && !cmd.isBlank()) {
b.addAllowRule(SHELL_TOOL_NAME, exactToken(cmd.trim(), PermissionBehavior.ALLOW));
}
}
}
return b.build();
}
/**
* BLACKLIST每条黑名单命令一条精确 deny + 一条 catch-all allow 兜底放行
*/
private static PermissionContextState blacklistContext(List<String> blacklist) {
PermissionContextState.Builder b = PermissionContextState.builder()
.mode(PermissionMode.DEFAULT);
if (blacklist != null) {
for (String cmd : blacklist) {
if (cmd != null && !cmd.isBlank()) {
b.addDenyRule(SHELL_TOOL_NAME, exactToken(cmd.trim(), PermissionBehavior.DENY));
}
}
}
// catch-all allow 兜底deny 先评估非黑名单命令落到此被放行
b.addAllowRule(SHELL_TOOL_NAME, catchAll(PermissionBehavior.ALLOW));
return b.build();
}
/* ----- rule factories ----- */
/** 精确匹配某命令首 token{@code ruleContent = token}(裸字符串,工具侧 equals 比对)。 */
private static PermissionRule exactToken(String token, PermissionBehavior behavior) {
return new PermissionRule(SHELL_TOOL_NAME, token, behavior, SOURCE);
}
/** catch-all{@code ruleContent = null}(引擎见 null 匹配一切)。 */
private static PermissionRule catchAll(PermissionBehavior behavior) {
return new PermissionRule(SHELL_TOOL_NAME, null, behavior, SOURCE);
}
/** 安全默认DEFAULT mode、无规则最保守回退不会误放行也不会误拒绝已配置的语义。 */
private static PermissionContextState safeDefault() {
return PermissionContextState.builder().mode(PermissionMode.DEFAULT).build();
}
}

View File

@ -0,0 +1,158 @@
package com.yomahub.liteflow.test.agent.v2;
import com.yomahub.liteflow.agent.permission.PermissionConfigMapper;
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.PermissionMode;
import io.agentscope.core.permission.PermissionRule;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 3.1 单元测试验证 {@link PermissionConfigMapper} liteflow {@link ShellConfig}
* mode/whitelist/blacklist正确翻译成 v2 {@link PermissionContextState} 的命令级规则
*
* <p>纯单元不构建 ReActAgent不调 LLM不起 Spring只断言规则结构mode + 三张规则表
*
* <p>matcher DSL 与默认裁决语义来自探针findings R6-ext
* <ul>
* <li>catch-all = {@code ruleContent == null}{@link PermissionEngine} null 即匹配一切</li>
* <li>引擎评估顺序denyRules askRules tool 自检 allowRules BYPASS defaultDEFAULT 模式 ASKDONT_ASK DENY</li>
* <li> allowRules 并不隐含"其余 deny"需显式兜底规则</li>
* </ul>
*/
class PermissionConfigMapperTest {
private static final String TOOL = "execute_shell_command";
/** DISABLED整体禁用 shell 工具——catch-all deny。 */
@Test
void disabled_mapsToCatchAllDeny() {
AgentConfig cfg = shellCfg(ShellMode.DISABLED, Collections.emptyList(), Collections.emptyList());
PermissionContextState ctx = PermissionConfigMapper.map(cfg);
assertNotNull(ctx, "map() 永远返回非 null");
assertEquals(PermissionMode.DEFAULT, ctx.getMode(), "DISABLED 用 DEFAULT mode 即可catch-all deny 已足够)");
// allow / ask 表为空
assertTrue(ctx.getAllowRules().isEmpty(), "DISABLED 不应有 allow 规则");
assertTrue(ctx.getAskRules().isEmpty(), "DISABLED 不应有 ask 规则");
// deny 表含一条 execute_shell_command catch-allruleContent == null
List<PermissionRule> denies = ctx.getDenyRules().get(TOOL);
assertNotNull(denies, "DISABLED 必须为 " + TOOL + " 注册 deny 规则");
assertEquals(1, denies.size(), "DISABLED 仅需一条 catch-all deny");
PermissionRule deny = denies.get(0);
assertEquals(TOOL, deny.toolName());
assertEquals(PermissionBehavior.DENY, deny.behavior());
assertNull(deny.ruleContent(), "catch-all deny 的 ruleContent 必须为 null");
}
/** WHITELIST白名单命令逐条 allowmode=DONT_ASK 使非白名单命令经 default 转 DENY。 */
@Test
void whitelist_mapsToPerCommandAllowInDontAskMode() {
AgentConfig cfg = shellCfg(ShellMode.WHITELIST, Arrays.asList("ls", "cat"), Collections.emptyList());
PermissionContextState ctx = PermissionConfigMapper.map(cfg);
assertEquals(PermissionMode.DONT_ASK, ctx.getMode(),
"WHITELIST 用 DONT_ASK白名单命中 allow 放行,非白名单经 default 转 DENY不能用 catch-all deny"
+ "否则 denyRules 优先级会先吞掉白名单)");
// deny / ask 表为空非白名单的拒绝靠 DONT_ASK default path不靠显式 deny 规则
assertTrue(ctx.getDenyRules().isEmpty(), "WHITELIST 不应有 deny 规则");
assertTrue(ctx.getAskRules().isEmpty(), "WHITELIST 不应有 ask 规则");
// allow 表含 ls / cat 两条精确匹配规则
List<PermissionRule> allows = ctx.getAllowRules().get(TOOL);
assertNotNull(allows);
assertEquals(2, allows.size(), "whitelist=[ls,cat] → 2 条 allow 规则");
for (PermissionRule r : allows) {
assertEquals(TOOL, r.toolName());
assertEquals(PermissionBehavior.ALLOW, r.behavior());
assertNotNull(r.ruleContent(), "每条 allow 的 ruleContent = 命令首 token非 null");
}
// 内容必须是 ls cat顺序无强约束 mapper 按配置顺序产出断言之
assertEquals("ls", allows.get(0).ruleContent());
assertEquals("cat", allows.get(1).ruleContent());
}
/** BLACKLIST黑名单命令逐条 deny + 一条 catch-all allow 兜底放行其余。deny 先于 allow 评估。 */
@Test
void blacklist_mapsToPerCommandDenyPlusCatchAllAllow() {
AgentConfig cfg = shellCfg(ShellMode.BLACKLIST, Collections.emptyList(), Collections.singletonList("rm"));
PermissionContextState ctx = PermissionConfigMapper.map(cfg);
assertEquals(PermissionMode.DEFAULT, ctx.getMode(),
"BLACKLIST 用 DEFAULT mode靠 catch-all allow 放行非黑名单,不依赖 mode 副作用");
assertTrue(ctx.getAskRules().isEmpty(), "BLACKLIST 不应有 ask 规则");
// deny 表含 rm 精确匹配
List<PermissionRule> denies = ctx.getDenyRules().get(TOOL);
assertNotNull(denies);
assertEquals(1, denies.size(), "blacklist=[rm] → 1 条 deny 规则");
PermissionRule deny = denies.get(0);
assertEquals(TOOL, deny.toolName());
assertEquals(PermissionBehavior.DENY, deny.behavior());
assertEquals("rm", deny.ruleContent(), "deny 规则 ruleContent = 黑名单命令首 token");
// allow 表含一条 catch-allruleContent == null兜底
List<PermissionRule> allows = ctx.getAllowRules().get(TOOL);
assertNotNull(allows, "BLACKLIST 必须有 catch-all allow 兜底,否则非黑名单命令落到 default ASK 而非 ALLOW");
assertEquals(1, allows.size());
PermissionRule allow = allows.get(0);
assertEquals(TOOL, allow.toolName());
assertEquals(PermissionBehavior.ALLOW, allow.behavior());
assertNull(allow.ruleContent(), "catch-all allow 的 ruleContent 必须为 null");
}
/** 默认构造 AgentConfigmode=WHITELIST 默认值 + 默认白名单)→ 非 null合理结构。 */
@Test
void defaultConfig_neverNull_andHasDefaultWhitelistAllows() {
AgentConfig cfg = new AgentConfig(); // shell.mode 默认 WHITELISTwhitelist 默认一批只读命令
PermissionContextState ctx = PermissionConfigMapper.map(cfg);
assertNotNull(ctx, "默认配置也必须返回非 null PermissionContextState");
assertEquals(PermissionMode.DONT_ASK, ctx.getMode(), "默认 mode=WHITELIST → DONT_ASK");
// 默认 whitelist 非空ShellConfig 默认 ~40 allow 规则非空
List<PermissionRule> allows = ctx.getAllowRules().get(TOOL);
assertNotNull(allows);
assertFalse(allows.isEmpty(), "默认白名单应产出 allow 规则");
}
/** map(null) 不 NPE——返回一个安全的默认 context永非 null 契约)。 */
@Test
void nullAgentConfig_returnsSafeDefault() {
PermissionContextState ctx = PermissionConfigMapper.map(null);
assertNotNull(ctx, "map(null) 也必须返回非 null");
// 不抛结构合理即可具体 mode/规则不强约束只要不 NPE 且非 null
}
/* ----- helpers ----- */
private static AgentConfig shellCfg(ShellMode mode, List<String> whitelist, List<String> blacklist) {
AgentConfig cfg = new AgentConfig();
ShellConfig shell = cfg.getShell();
shell.setMode(mode);
shell.setWhitelist(whitelist);
shell.setBlacklist(blacklist);
return cfg;
}
}