diff --git a/docs/superpowers/specs/v2-api-findings.md b/docs/superpowers/specs/v2-api-findings.md index eb19026b0..3f6e1b5d7 100644 --- a/docs/superpowers/specs/v2-api-findings.md +++ b/docs/superpowers/specs/v2-api-findings.md @@ -228,6 +228,55 @@ PermissionContextState.builder() --- +## R6-ext:matcher 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 input) { + String content = rule.ruleContent(); + if (content == null || content.isEmpty()) { + return true; // ← catch-all:null/空 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`,默认 passthrough;EXPLORE/ACCEPT_EDITS read-only 在此)→ allowRules → BYPASS 模式放行 → **default ASK(DONT_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 input` 即工具收到的 `toolInput`。字段名由工具的 `@Tool`/`@ToolParam` 参数名决定——对自建 `ManagedShellCommandTool` 而言就是其参数名(约定 `command`)。 + +**对 Task 3.1(PermissionConfigMapper)的最终指导(已采用):** + +由于 matcher DSL 完全由自建工具定义,Task 3.1 与 Task 3.2(恢复 `ManagedShellCommandTool`)**共同约定**一套简单、易解析的 `ruleContent` 格式: +- **每条命令一行精确匹配**:`ruleContent = ""`(即裸 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, "", ALLOW, "shell-config"))`;**外加一条** `addDenyRule(TOOL, new PermissionRule(TOOL, null, DENY, "shell-config"))` 兜底,使非白名单命令稳定 DENY(而非落到 default ASK)。deny 优先级高于 allow,但白名单 allow 与 catch-all deny 不会冲突:catch-all 只匹配"未被前面 allow 规则认领"的调用(实际引擎是逐条 evaluate,denyRules 先扫——见下注意)。 + - **⚠️ 顺序注意**:引擎先评估 denyRules 再评估 allowRules。若 catch-all deny 放在 WHITELIST,它会在 allowRules 之前命中**所有**命令(包括白名单内的),导致白名单也被 deny。**因此 WHITELIST 模式不能用 catch-all deny 实现兜底**——正确做法:**只加白名单 allow 规则,mode 设为 `DONT_ASK`**,使未命中白名单的调用经 default path 转 DENY(DONT_ASK 模式 defaultDecisionAsk 返回 DENY)。这是 RC3 下表达"白名单"语义的唯一正确方式。 +- **BLACKLIST**:每条黑名单命令一条 `addDenyRule(TOOL, new PermissionRule(TOOL, "", 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 评估,黑名单硬拒、其余放行)。 + +--- + ## R7:JDK 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 成立。 diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java index 598c3fe83..fa564061a 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java @@ -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} 方法)。 *
  • {@code maxIters} — {@code cmp.maxIterations() > 0 ? it : cfg.defaults.maxIterations}。
  • *
  • {@code stateStore} — {@link AgentStateStoreResolver#resolve(AgentConfig)}(可能返回 null=NONE,合法)。
  • + *
  • {@code permissionContext} — {@link PermissionConfigMapper#map(AgentConfig)}(ShellConfig→v2 命令级规则,Task 3.1)。
  • * * *

    不设(留给后续 Task / RC3 无): * {@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)。本类不引用这些协作者。 * *

    线程安全

    @@ -119,6 +122,10 @@ public final class ReactAgentFactory { } AgentStateStore stateStore = AgentStateStoreResolver.resolve(cfg); + // ShellConfig → v2 PermissionContextState 命令级规则(findings R6-ext);永非 null, + // 在工具调用前由 v2 PermissionEngine 裁决 execute_shell_command。Task 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( diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/permission/PermissionConfigMapper.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/permission/PermissionConfigMapper.java new file mode 100644 index 000000000..a38c98c6a --- /dev/null +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/permission/PermissionConfigMapper.java @@ -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} 在工具调用前裁决。 + * + *

    工具名

    + * 所有规则 target {@link #SHELL_TOOL_NAME},即 Task 3.2 将恢复的 + * {@code ManagedShellCommandTool} 的 {@code @Tool(name=...)}(与 1.0 一致)。 + * + *

    matcher DSL(findings R6-ext,实测 RC3 源码)

    + * {@code PermissionEngine} 不解析 {@code ruleContent}——匹配完全委托给 + * {@code ToolBase.matchRule(content, input)}。RC3 core 里没有任何 + * {@code ToolBase} 子类覆写 {@code matchRule}(基类实现 {@code return ruleContent == null;} + * 只认 catch-all),故本 mapper 与 Task 3.2 的 {@code ManagedShellCommandTool.matchRule} + * 共同约定一套简单格式: + *
      + *
    • 精确匹配某命令首 token:{@code ruleContent = ""}(裸字符串,如 {@code "ls"})。 + * Task 3.2 的工具取 {@code input.get("command")} 首 token 与之 {@code equals} 比对。
    • + *
    • catch-all(匹配一切):{@code ruleContent = null}(引擎原生支持)。
    • + *
    + * + *

    各 mode 的规则构造

    + * 引擎评估顺序(实测):denyRules → askRules → 工具自检 → allowRules → BYPASS → default + * (DEFAULT 模式 default=ASK,DONT_ASK 模式 default=DENY)。有 allowRules 并不隐含"其余 deny"。 + *
      + *
    • {@link ShellMode#DISABLED}:单条 catch-all deny({@code ruleContent=null}), + * mode {@link PermissionMode#DEFAULT}。整条工具禁用。
    • + *
    • {@link ShellMode#WHITELIST}(默认):每条白名单命令一条精确 allow 规则, + * mode {@link PermissionMode#DONT_ASK}。白名单命令命中 allow 放行;非白名单不命中任何规则, + * 经 default path + DONT_ASK 转 DENY。注意:不能用 catch-all deny 兜底——denyRules + * 先于 allowRules 评估,catch-all deny 会把白名单命令也一并吞掉。DONT_ASK 是唯一干净表达 + * "白名单才放行"的 RC3 语义。
    • + *
    • {@link ShellMode#BLACKLIST}:每条黑名单命令一条精确 deny 规则 + 一条 catch-all + * allow 兜底,mode {@link PermissionMode#DEFAULT}。deny 先于 allow 评估:黑名单命令被前面 deny + * 拒绝,其余落到后面 catch-all allow 放行。不依赖 mode 副作用(不用 BYPASS——它对所有工具全放行、 + * 且绕过规则评估,粒度太粗)。
    • + *
    + * + *

    永非 null 契约

    + * {@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 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 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(); + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/PermissionConfigMapperTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/PermissionConfigMapperTest.java new file mode 100644 index 000000000..82714807a --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/PermissionConfigMapperTest.java @@ -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} 的命令级规则。 + * + *

    纯单元:不构建 ReActAgent、不调 LLM、不起 Spring。只断言规则结构(mode + 三张规则表)。 + * + *

    matcher DSL 与默认裁决语义来自探针(findings R6-ext): + *

      + *
    • catch-all = {@code ruleContent == null}({@link PermissionEngine} 见 null 即匹配一切)。
    • + *
    • 引擎评估顺序:denyRules → askRules → tool 自检 → allowRules → BYPASS → default(DEFAULT 模式 ASK,DONT_ASK 转 DENY)。
    • + *
    • 有 allowRules 并不隐含"其余 deny"——需显式兜底规则。
    • + *
    + */ +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-all(ruleContent == null) + List 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:白名单命令逐条 allow,mode=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 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 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-all(ruleContent == null)兜底 + List 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"); + } + + /** 默认构造 AgentConfig(mode=WHITELIST 默认值 + 默认白名单)→ 非 null,合理结构。 */ + @Test + void defaultConfig_neverNull_andHasDefaultWhitelistAllows() { + AgentConfig cfg = new AgentConfig(); // shell.mode 默认 WHITELIST,whitelist 默认一批只读命令 + + PermissionContextState ctx = PermissionConfigMapper.map(cfg); + + assertNotNull(ctx, "默认配置也必须返回非 null PermissionContextState"); + assertEquals(PermissionMode.DONT_ASK, ctx.getMode(), "默认 mode=WHITELIST → DONT_ASK"); + // 默认 whitelist 非空(ShellConfig 默认 ~40 条),故 allow 规则非空 + List 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 whitelist, List blacklist) { + AgentConfig cfg = new AgentConfig(); + ShellConfig shell = cfg.getShell(); + shell.setMode(mode); + shell.setWhitelist(whitelist); + shell.setBlacklist(blacklist); + return cfg; + } +}