From edf2b683ded10894245fe981a5433c91dd8d4b6e Mon Sep 17 00:00:00 2001 From: xieshuang <1312544013@qq.com> Date: Thu, 19 Mar 2026 21:49:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(job):=20=E5=AE=8C=E5=96=84=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E4=BB=BB=E5=8A=A1=E5=8A=9F=E8=83=BD=E4=B8=8E=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E5=A4=84=E7=90=86=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 AGENTS.md 中新增时间与定时任务规范章节,明确时间判断、任务创建和执行要求 - 优化 add_job 工具描述,强调任务创建成功验证和 prompt 自包含性要求 - 实现定时任务触发时的消息包装,包含 [定时任务触发] 标识和执行指令 - 修复 once_delay 模式下 initialDelay 为 0 时使用 scheduleValue 的逻辑 - 增加对仅含注释的 HEARTBEAT.md 文件跳过心跳执行的测试验证 - 新增 WorkspaceJobService 测试类,验证定时任务调度和触发执行功能 --- .../claw/agent/job/WorkspaceJobService.java | 22 ++- .../com/jimuqu/claw/agent/tool/JobTools.java | 18 +- src/main/resources/template/AGENTS.md | 23 +++ .../agent/job/WorkspaceJobServiceTest.java | 169 ++++++++++++++++++ .../agent/runtime/HeartbeatServiceTest.java | 44 +++++ .../agent/tool/WorkspaceAgentToolsTest.java | 40 ----- 6 files changed, 267 insertions(+), 49 deletions(-) create mode 100644 src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java diff --git a/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java b/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java index cba325d..a36f490 100644 --- a/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java +++ b/src/main/java/com/jimuqu/claw/agent/job/WorkspaceJobService.java @@ -139,7 +139,7 @@ public class WorkspaceJobService { if (replyTarget == null || StrUtil.isBlank(definition.getSessionKey()) || jobDispatcher == null) { return; } - jobDispatcher.dispatch(definition.getSessionKey(), replyTarget, definition.getPrompt()); + jobDispatcher.dispatch(definition.getSessionKey(), replyTarget, buildDispatchPrompt(definition)); if (isOneShot(definition)) { removeJob(definition.getName()); } @@ -156,9 +156,10 @@ public class WorkspaceJobService { } private Scheduled buildScheduled(JobDefinition definition) { + long initialDelay = Math.max(0L, definition.getInitialDelay()); ScheduledAnno scheduled = new ScheduledAnno() .name(definition.getName()) - .initialDelay(definition.getInitialDelay()) + .initialDelay(initialDelay) .enable(definition.isEnabled()); if (StrUtil.isNotBlank(definition.getZone())) { @@ -171,9 +172,10 @@ public class WorkspaceJobService { } else if ("fixed_delay".equals(mode)) { scheduled.fixedDelay(parseLong(definition.getScheduleValue(), "fixedDelay")); } else if ("once_delay".equals(mode)) { - long delay = definition.getInitialDelay() > 0 - ? definition.getInitialDelay() + long delay = initialDelay > 0 + ? initialDelay : parseLong(definition.getScheduleValue(), "onceDelay"); + scheduled.initialDelay(delay); scheduled.fixedDelay(delay); } else if ("cron".equals(mode)) { scheduled.cron(definition.getScheduleValue()); @@ -184,6 +186,18 @@ public class WorkspaceJobService { return scheduled; } + private String buildDispatchPrompt(JobDefinition definition) { + StringBuilder builder = new StringBuilder(); + builder.append("[定时任务触发]").append('\n'); + builder.append("任务名称: ").append(definition.getName()).append('\n'); + builder.append("任务模式: ").append(definition.getMode()).append('\n'); + builder.append("当前这次触发已经到执行时间。请你现在立刻完成下面的任务,并直接在当前会话给出结果。").append('\n'); + builder.append("不要把这条消息再理解成“稍后执行”,也不要再次创建同一个定时任务,除非用户明确要求循环执行。").append('\n'); + builder.append("任务内容:").append('\n'); + builder.append(definition.getPrompt()); + return builder.toString(); + } + private long parseLong(String value, String fieldName) { try { return Long.parseLong(value); diff --git a/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java b/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java index 7a6ee5a..8933800 100644 --- a/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java +++ b/src/main/java/com/jimuqu/claw/agent/tool/JobTools.java @@ -28,14 +28,22 @@ public class JobTools { return definition == null ? "任务不存在: " + name : JSONUtil.toJsonPrettyStr(definition); } - @ToolMapping(name = "add_job", description = "新增定时任务。mode 仅支持 fixed_rate、fixed_delay、once_delay、cron;fixed_* 与 once_delay 的 scheduleValue 单位为毫秒") + @ToolMapping( + name = "add_job", + description = "新增定时任务。对时间触发型需求,只有实际成功调用本工具后,才能声称“已安排”或“已创建”。" + + "mode 仅支持 fixed_rate、fixed_delay、once_delay、cron;fixed_* 与 once_delay 的 scheduleValue 单位为毫秒。" + + "prompt 必须是未来触发时可独立执行的完整任务指令,要求自包含、可脱离当前对话单独理解," + + "不能只是对当前对话的复述,也不能保留依赖当前语境的省略表达。" + + "创建任务后,当前轮回复只应基于本工具的真实返回结果确认已安排的时间、频率和任务目标," + + "不要把未来真正要执行的结果提前在当前轮发送。" + ) public String addJob( - @Param(description = "任务名称") String name, + @Param(description = "任务名称。应稳定、清晰,便于后续查询、删除或覆盖") String name, @Param(description = "调度模式:fixed_rate、fixed_delay、once_delay、cron") String mode, @Param(description = "调度值:cron 表达式或毫秒值") String scheduleValue, - @Param(description = "触发时提交给 Agent 的任务提示词") String prompt, - @Param(description = "首次执行前延迟毫秒数,可填 0") long initialDelay, - @Param(description = "时区,可为空") String zone + @Param(description = "未来触发时交给 Agent 执行的完整任务指令。必须自包含、可独立理解,能够直接指导未来那次执行") String prompt, + @Param(description = "首次执行前延迟毫秒数,可填 0。对 once_delay,若大于 0,会优先作为真实延迟") long initialDelay, + @Param(description = "时区,可为空;cron 任务建议显式提供,例如 Asia/Shanghai") String zone ) { return JSONUtil.toJsonPrettyStr( workspaceJobService.addJob(name, mode, scheduleValue, prompt, initialDelay, zone) diff --git a/src/main/resources/template/AGENTS.md b/src/main/resources/template/AGENTS.md index 2b7e365..d7a7aaa 100644 --- a/src/main/resources/template/AGENTS.md +++ b/src/main/resources/template/AGENTS.md @@ -91,6 +91,29 @@ - 子任务完成后,父任务应负责统一汇总和最终对外回复 - 不要为了很小的动作滥用子任务,也不要无边界连续扇出 +## 时间与定时任务 + +- 涉及“几秒后、几分钟后、几点、今天、明天、稍后、到时候”等时间表达时,必须以当前这次运行可获得的真实当前时间为准进行判断和换算,不能依赖历史对话中出现过的时间、记忆里的旧时间、上一次看到的时间,或自行猜测的当前时间 +- 如果当前运行没有可靠的“当前时间”来源,就明确说明“无法确认当前时间”,不要自行编造现在几点,也不要基于不确定时间创建任务 +- 对相对时间表达,应先基于真实当前时间换算出绝对触发时间,再创建任务 +- 对绝对时间表达,必须结合真实当前时间判断这是今天稍后、明天,还是一个已经过去的时间;不要默认把已过期时间当作未来时间 +- 除非用户明确要求你使用其他机制,否则所有时间触发型需求都优先使用 `add_job` +- 对时间触发型需求,是否成功必须以 `add_job` 等定时任务工具实际调用成功为准;只有工具成功返回后,才能对用户声称已创建、已安排、已设置或将会按时执行 +- 如果没有真正调用 `add_job`,或工具调用失败、参数不完整、结果不确定,就必须明确说明“尚未创建成功”以及原因,不能仅凭语言生成一个看似已安排的回复 +- 创建定时任务时,要先把用户当前意图抽象成“未来触发时可独立执行的任务指令”,再作为 `add_job` 的 `prompt` 保存 +- `add_job` 的 `prompt` 必须自包含、可脱离当前对话单独理解;不要把依赖当下语境、代词指代、临时省略、上下文暗示的表达原样保存进去 +- 生成 `prompt` 时,应保留用户真实目标、对象、约束和期望结果,把口语化请求整理成未来可直接执行的任务描述,而不是简单复述用户原话 +- 如果用户目标已经足够明确,直接生成可执行 `prompt`,不要为了形式完整而追问显而易见的信息 +- 只有在关键目标、对象或执行前提缺失,并且这种缺失会导致未来任务无法正确执行时,才允许提问补充 +- 创建定时任务后,不要在当前运行里再次自行等待并额外通知一次;后续提醒只应由定时任务触发 +- 创建定时任务后的当前回复,只需要基于工具实际返回结果确认已安排的时间、频率和任务目标;不要把未来真正要执行的结果提前在当前轮先发一次 +- 当收到 `[定时任务触发]` 时,表示任务已经到达执行时间;此时你处于“执行任务”阶段,不是“创建任务”或“解释任务配置”阶段 +- 这类消息中的“任务内容”就是本次真正要执行的指令;你需要自己理解任务目标、决定执行路径,并直接产出本次执行结果 +- 执行定时任务时,默认目标是“完成任务并返回结果”,而不是解释任务配置、汇报任务已创建、描述后台状态,或把本次触发重新理解为未来安排 +- 如果任务内容要求输出提醒、汇报、检查结果、分析结论、文件处理结果或工具执行结果,就直接给出这些结果;不要只复述任务要求 +- 如果任务内容需要读取工作区、调用工具、查询信息或执行步骤,应先完成这些动作,再返回最终结果 +- 只有在完成本次任务所需信息确实不足、且当前无法自行补全时,才简短说明缺少什么;不要退回到“已记录需求”“稍后执行”“届时再说”这类创建态表达 + ## Heartbeat - 收到 heartbeat 时,优先读取 `HEARTBEAT.md` diff --git a/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java b/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java new file mode 100644 index 0000000..44d9626 --- /dev/null +++ b/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java @@ -0,0 +1,169 @@ +package com.jimuqu.claw.agent.job; + +import com.jimuqu.claw.agent.model.ChannelType; +import com.jimuqu.claw.agent.model.ConversationType; +import com.jimuqu.claw.agent.model.ReplyTarget; +import com.jimuqu.claw.agent.store.RuntimeStoreService; +import com.jimuqu.claw.agent.workspace.AgentWorkspaceService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.noear.solon.scheduling.annotation.Scheduled; +import org.noear.solon.scheduling.scheduled.JobHandler; +import org.noear.solon.scheduling.scheduled.JobHolder; +import org.noear.solon.scheduling.scheduled.JobInterceptor; +import org.noear.solon.scheduling.scheduled.manager.IJobManager; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkspaceJobServiceTest { + @Test + void onceDelayUsesScheduleValueAsInitialDelayWhenInitialDelayIsZero(@TempDir Path tempDir) { + Fixture fixture = new Fixture(tempDir); + + fixture.service.addJob( + "weather-once", + "once_delay", + "60000", + "1分钟后再次汇报重庆天气", + 0L, + "" + ); + + JobHolder holder = fixture.jobManager.jobGet("weather-once"); + assertNotNull(holder); + assertEquals(60000L, holder.getScheduled().initialDelay()); + assertEquals(60000L, holder.getScheduled().fixedDelay()); + } + + @Test + void triggeredJobWrapsPromptAsImmediateExecutionInstruction(@TempDir Path tempDir) throws Throwable { + Fixture fixture = new Fixture(tempDir); + List dispatched = new ArrayList<>(); + fixture.service.setJobDispatcher((sessionKey, replyTarget, prompt) -> { + dispatched.add(sessionKey); + dispatched.add(replyTarget.getConversationId()); + dispatched.add(prompt); + return "run-1"; + }); + + fixture.service.addJob( + "weather-once", + "once_delay", + "60000", + "1分钟后再次汇报重庆天气", + 0L, + "" + ); + + fixture.jobManager.trigger("weather-once"); + + assertEquals(3, dispatched.size()); + assertEquals("dingtalk:group:group-1", dispatched.get(0)); + assertEquals("group-1", dispatched.get(1)); + assertTrue(dispatched.get(2).contains("[定时任务触发]")); + assertTrue(dispatched.get(2).contains("现在立刻完成下面的任务")); + assertTrue(dispatched.get(2).contains("不要把这条消息再理解成“稍后执行”")); + assertTrue(dispatched.get(2).contains("1分钟后再次汇报重庆天气")); + assertTrue(!fixture.jobManager.jobExists("weather-once")); + } + + private static final class Fixture { + private final FakeJobManager jobManager = new FakeJobManager(); + private final WorkspaceJobService service; + + private Fixture(Path tempDir) { + AgentWorkspaceService workspaceService = new AgentWorkspaceService(tempDir.toString()); + JobStoreService jobStoreService = new JobStoreService(workspaceService); + RuntimeStoreService runtimeStoreService = new RuntimeStoreService(workspaceService.fileInWorkspace("runtime")); + runtimeStoreService.rememberReplyTarget( + "dingtalk:group:group-1", + new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1") + ); + this.service = new WorkspaceJobService(jobManager, jobStoreService, runtimeStoreService); + this.service.setJobDispatcher((sessionKey, replyTarget, prompt) -> "run-0"); + } + } + + private static final class FakeJobManager implements IJobManager { + private final Map jobs = new LinkedHashMap<>(); + + @Override + public void addJobInterceptor(int index, JobInterceptor interceptor) { + } + + @Override + public boolean hasJobInterceptor() { + return false; + } + + @Override + public List> getJobInterceptors() { + return new ArrayList<>(); + } + + @Override + public JobHolder jobAdd(String name, Scheduled scheduled, JobHandler handler) { + return jobAdd(name, scheduled, handler, null); + } + + @Override + public JobHolder jobAdd(String name, Scheduled scheduled, JobHandler handler, Map data) { + JobHolder holder = new JobHolder(this, name, scheduled, handler); + holder.setData(data); + jobs.put(name, holder); + return holder; + } + + @Override + public boolean jobExists(String name) { + return jobs.containsKey(name); + } + + @Override + public JobHolder jobGet(String name) { + return jobs.get(name); + } + + @Override + public Map jobGetAll() { + return jobs; + } + + @Override + public void jobRemove(String name) { + jobs.remove(name); + } + + @Override + public void jobStart(String name, Map data) { + } + + @Override + public void jobStop(String name) { + } + + @Override + public boolean isStarted() { + return false; + } + + @Override + public void start() { + } + + public void trigger(String name) throws Throwable { + JobHolder holder = jobs.get(name); + if (holder != null) { + holder.getHandler().handle(null); + } + } + } +} diff --git a/src/test/java/com/jimuqu/claw/agent/runtime/HeartbeatServiceTest.java b/src/test/java/com/jimuqu/claw/agent/runtime/HeartbeatServiceTest.java index 2e3f01b..b02c06a 100644 --- a/src/test/java/com/jimuqu/claw/agent/runtime/HeartbeatServiceTest.java +++ b/src/test/java/com/jimuqu/claw/agent/runtime/HeartbeatServiceTest.java @@ -76,6 +76,50 @@ class HeartbeatServiceTest { } } + /** + * 验证只有注释的 HEARTBEAT.md 不会触发心跳运行。 + */ + @Test + void tickIgnoresCommentOnlyHeartbeatFile() throws Exception { + Path workspace = tempDir.resolve("workspace"); + FileUtil.mkdir(workspace.toFile()); + FileUtil.writeUtf8String( + "# HEARTBEAT.md\n\n# 保持此文件为空(或仅包含注释)以跳过心跳 API 调用。\n\n# 默认注释不应触发心跳。\n", + workspace.resolve("HEARTBEAT.md").toFile() + ); + + RuntimeStoreService store = new RuntimeStoreService(tempDir.resolve("runtime").toFile()); + ReplyTarget replyTarget = new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-9", "user-9"); + store.rememberReplyTarget("dingtalk:group:group-9", replyTarget); + + CountDownLatch executed = new CountDownLatch(1); + ConversationAgent conversationAgent = (request, progressConsumer) -> { + executed.countDown(); + return "heartbeat:" + request.getCurrentMessage(); + }; + ConversationScheduler scheduler = new ConversationScheduler(1); + ChannelRegistry registry = new ChannelRegistry(); + RecordingChannelAdapter adapter = new RecordingChannelAdapter(); + registry.register(adapter); + + SolonClawProperties properties = new SolonClawProperties(); + properties.setWorkspace(workspace.toString()); + + try { + AgentRuntimeService runtimeService = new AgentRuntimeService(conversationAgent, store, scheduler, registry, properties); + HeartbeatService heartbeatService = new HeartbeatService(runtimeService, store, properties); + + heartbeatService.tick(); + + assertTrue(!executed.await(500, TimeUnit.MILLISECONDS)); + Thread.sleep(200); + assertTrue(adapter.messages.isEmpty()); + assertEquals(0, store.readConversationEvents("dingtalk:group:group-9").size()); + } finally { + scheduler.shutdown(); + } + } + /** * 记录测试发送消息的伪渠道适配器。 */ diff --git a/src/test/java/com/jimuqu/claw/agent/tool/WorkspaceAgentToolsTest.java b/src/test/java/com/jimuqu/claw/agent/tool/WorkspaceAgentToolsTest.java index 9235e54..e69de29 100644 --- a/src/test/java/com/jimuqu/claw/agent/tool/WorkspaceAgentToolsTest.java +++ b/src/test/java/com/jimuqu/claw/agent/tool/WorkspaceAgentToolsTest.java @@ -1,40 +0,0 @@ -package com.jimuqu.claw.agent.tool; - -import com.jimuqu.claw.agent.workspace.AgentWorkspaceService; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class WorkspaceAgentToolsTest { - @Test - void readsWritesAndEditsWithinWorkspace(@TempDir Path tempDir) throws Exception { - AgentWorkspaceService workspaceService = new AgentWorkspaceService(tempDir.toString()); - WorkspaceAgentTools tools = new WorkspaceAgentTools(workspaceService); - - String writeResult = tools.writeFile("notes/test.txt", "hello"); - assertTrue(writeResult.contains("已写入文件")); - - String readResult = tools.readFile("notes/test.txt"); - assertTrue(readResult.contains("hello")); - - String editResult = tools.editFile("notes/test.txt", "hello", "world"); - assertTrue(editResult.contains("已修改文件")); - - String edited = new String(Files.readAllBytes(tempDir.resolve("notes").resolve("test.txt")), StandardCharsets.UTF_8); - assertTrue(edited.contains("world")); - } - - @Test - void rejectsPathsOutsideWorkspace(@TempDir Path tempDir) { - AgentWorkspaceService workspaceService = new AgentWorkspaceService(tempDir.toString()); - WorkspaceAgentTools tools = new WorkspaceAgentTools(workspaceService); - - assertThrows(IllegalArgumentException.class, () -> tools.writeFile("..\\escape.txt", "x")); - } -} -- Gitee From 51ac9540f3ec1f959cb8794b4c4b011969fcfbd4 Mon Sep 17 00:00:00 2001 From: xieshuang <1312544013@qq.com> Date: Fri, 20 Mar 2026 15:16:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(agent):=20=E6=B7=BB=E5=8A=A0bash?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E9=BB=91=E5=90=8D=E5=8D=95=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入BlacklistProperties配置类支持命令、路径、正则模式三种黑名单 - 实现BlacklistInterceptor拦截器对bash工具调用进行安全检查 - 在SolonAiConversationAgent中集成HITLInterceptor支持黑名单功能 - 添加GOVERNANCE.md治理规则模板指导AI操作权限管理 - 删除废弃的WorkspaceJobServiceTest测试文件 - 配置自动加载治理规则模板并集成到系统提示中 --- .../impl/SolonAiConversationAgent.java | 19 +- .../workspace/WorkspacePromptService.java | 4 + .../jimuqu/claw/config/SolonClawConfig.java | 22 ++- .../claw/config/props/AgentProperties.java | 2 + .../config/props/BlacklistProperties.java | 26 +++ .../constitution/BlacklistInterceptor.java | 131 ++++++++++++++ src/main/resources/template/GOVERNANCE.md | 69 +++++++ .../agent/job/WorkspaceJobServiceTest.java | 169 ------------------ .../BlacklistInterceptorTest.java | 119 ++++++++++++ 9 files changed, 386 insertions(+), 175 deletions(-) create mode 100644 src/main/java/com/jimuqu/claw/config/props/BlacklistProperties.java create mode 100644 src/main/java/com/jimuqu/claw/constitution/BlacklistInterceptor.java create mode 100644 src/main/resources/template/GOVERNANCE.md delete mode 100644 src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java create mode 100644 src/test/java/com/jimuqu/claw/constitution/BlacklistInterceptorTest.java diff --git a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java index c4e1e08..53f22bd 100644 --- a/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java +++ b/src/main/java/com/jimuqu/claw/agent/runtime/impl/SolonAiConversationAgent.java @@ -12,6 +12,7 @@ import com.jimuqu.claw.agent.workspace.WorkspacePromptService; import cn.hutool.core.util.StrUtil; import org.noear.solon.ai.agent.AgentChunk; import org.noear.solon.ai.agent.react.ReActAgent; +import org.noear.solon.ai.agent.react.intercept.HITLInterceptor; import org.noear.solon.ai.chat.ChatModel; import org.noear.solon.ai.chat.message.ChatMessage; import org.noear.solon.ai.skills.cli.CliSkillProvider; @@ -34,6 +35,8 @@ public class SolonAiConversationAgent implements ConversationAgent { private final CliSkillProvider cliSkillProvider; /** 定时任务工具。 */ private final JobTools jobTools; + /** 黑名单拦截器(可选)。 */ + private final HITLInterceptor blacklistInterceptor; /** * 创建基于聊天模型的会话执行 Agent。 @@ -43,19 +46,22 @@ public class SolonAiConversationAgent implements ConversationAgent { * @param workspaceAgentTools 工作区工具集 * @param cliSkillProvider CLI 技能提供者 * @param jobTools 定时任务工具 + * @param blacklistInterceptor 黑名单拦截器,为 null 时不启用 */ public SolonAiConversationAgent( ChatModel chatModel, WorkspacePromptService workspacePromptService, WorkspaceAgentTools workspaceAgentTools, CliSkillProvider cliSkillProvider, - JobTools jobTools + JobTools jobTools, + HITLInterceptor blacklistInterceptor ) { this.chatModel = chatModel; this.workspacePromptService = workspacePromptService; this.workspaceAgentTools = workspaceAgentTools; this.cliSkillProvider = cliSkillProvider; this.jobTools = jobTools; + this.blacklistInterceptor = blacklistInterceptor; } /** @@ -114,7 +120,7 @@ public class SolonAiConversationAgent implements ConversationAgent { request == null ? null : request.getRunQuerySupport(), request == null ? null : request.getNotificationSupport() ); - return ReActAgent.of(chatModel) + ReActAgent.Builder builder = ReActAgent.of(chatModel) .name(workspacePromptService.resolveAgentName()) .instruction(workspacePromptService.buildSystemPrompt(request)) .defaultToolAdd(runtimeTools) @@ -122,8 +128,13 @@ public class SolonAiConversationAgent implements ConversationAgent { .defaultSkillAdd(cliSkillProvider) .maxSteps(50) .retryConfig(5, 1000L) - .sessionWindowSize(64) - .build(); + .sessionWindowSize(64); + + if (blacklistInterceptor != null) { + builder.defaultInterceptorAdd(blacklistInterceptor); + } + + return builder.build(); } /** diff --git a/src/main/java/com/jimuqu/claw/agent/workspace/WorkspacePromptService.java b/src/main/java/com/jimuqu/claw/agent/workspace/WorkspacePromptService.java index b9dbf3a..c704bd4 100644 --- a/src/main/java/com/jimuqu/claw/agent/workspace/WorkspacePromptService.java +++ b/src/main/java/com/jimuqu/claw/agent/workspace/WorkspacePromptService.java @@ -68,6 +68,8 @@ public class WorkspacePromptService { static final String IDENTITY_FILE = "IDENTITY.md"; /** 长期记忆文件名。 */ static final String MEMORY_FILE = "MEMORY.md"; + /** 治理规则文件名。 */ + static final String GOVERNANCE_FILE = "GOVERNANCE.md"; /** 每日记忆目录名。 */ static final String DAILY_MEMORY_DIR = "memory"; /** 每日记忆文件名格式。 */ @@ -174,6 +176,7 @@ public class WorkspacePromptService { writeTemplateIfMissing(USER_FILE); writeTemplateIfMissing(HEARTBEAT_FILE); writeTemplateIfMissing(MEMORY_FILE); + writeTemplateIfMissing(GOVERNANCE_FILE); if (brandNewWorkspace) { writeTemplateIfMissing(BOOTSTRAP_FILE); @@ -283,6 +286,7 @@ public class WorkspacePromptService { appendSection(lines, "身份记录", IDENTITY_FILE); appendSection(lines, "用户画像", USER_FILE); appendSection(lines, "工具备注", TOOLS_FILE); + appendSection(lines, "治理规则", GOVERNANCE_FILE); appendSection(lines, "首次对话引导", BOOTSTRAP_FILE); appendSection(lines, "长期记忆", MEMORY_FILE); appendRecentDailyMemory(lines); diff --git a/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java b/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java index ba510b5..8069971 100644 --- a/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java +++ b/src/main/java/com/jimuqu/claw/config/SolonClawConfig.java @@ -10,6 +10,9 @@ import com.jimuqu.claw.agent.runtime.impl.HeartbeatService; import com.jimuqu.claw.agent.runtime.impl.SolonAiConversationAgent; import com.jimuqu.claw.agent.store.RuntimeStoreService; import com.jimuqu.claw.agent.tool.JobTools; +import com.jimuqu.claw.constitution.BlacklistInterceptor; +import com.jimuqu.claw.config.props.BlacklistProperties; +import org.noear.solon.ai.agent.react.intercept.HITLInterceptor; import com.jimuqu.claw.agent.tool.WorkspaceAgentTools; import com.jimuqu.claw.agent.workspace.AgentWorkspaceService; import com.jimuqu.claw.agent.workspace.WorkspacePromptService; @@ -170,6 +173,19 @@ public class SolonClawConfig { return new ConversationScheduler(properties.getAgent().getScheduler().getMaxConcurrentPerConversation()); } + /** + * 创建黑名单拦截器。 + * 始终创建,空黑名单不会拦截任何命令。 + */ + @Bean + public HITLInterceptor blacklistInterceptor(SolonClawProperties properties) { + BlacklistProperties blacklistProps = properties.getAgent().getBlacklist(); + BlacklistInterceptor strategy = new BlacklistInterceptor( + blacklistProps.isEnabled() ? blacklistProps : null + ); + return new HITLInterceptor().onTool("bash", strategy); + } + /** * 创建会话执行 Agent。 * @@ -183,14 +199,16 @@ public class SolonClawConfig { WorkspacePromptService workspacePromptService, WorkspaceAgentTools workspaceAgentTools, CliSkillProvider cliSkillProvider, - JobTools jobTools + JobTools jobTools, + HITLInterceptor blacklistInterceptor ) { return new SolonAiConversationAgent( chatModel, workspacePromptService, workspaceAgentTools, cliSkillProvider, - jobTools + jobTools, + blacklistInterceptor ); } diff --git a/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java b/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java index dc5e457..b303a6e 100644 --- a/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java +++ b/src/main/java/com/jimuqu/claw/config/props/AgentProperties.java @@ -23,4 +23,6 @@ public class AgentProperties implements Serializable { private SubtasksProperties subtasks = new SubtasksProperties(); /** 心跳配置。 */ private HeartbeatProperties heartbeat = new HeartbeatProperties(); + /** 黑名单配置。 */ + private BlacklistProperties blacklist = new BlacklistProperties(); } diff --git a/src/main/java/com/jimuqu/claw/config/props/BlacklistProperties.java b/src/main/java/com/jimuqu/claw/config/props/BlacklistProperties.java new file mode 100644 index 0000000..41a8f36 --- /dev/null +++ b/src/main/java/com/jimuqu/claw/config/props/BlacklistProperties.java @@ -0,0 +1,26 @@ +package com.jimuqu.claw.config.props; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 黑名单配置。 + */ +@Data +@NoArgsConstructor +public class BlacklistProperties implements Serializable { + private static final long serialVersionUID = 1L; + + /** 是否启用黑名单拦截。 */ + private boolean enabled = true; + /** 用户追加的命令关键词黑名单。 */ + private List extraCommands = new ArrayList<>(); + /** 用户追加的路径黑名单。 */ + private List extraPaths = new ArrayList<>(); + /** 用户追加的正则模式黑名单。 */ + private List extraPatterns = new ArrayList<>(); +} diff --git a/src/main/java/com/jimuqu/claw/constitution/BlacklistInterceptor.java b/src/main/java/com/jimuqu/claw/constitution/BlacklistInterceptor.java new file mode 100644 index 0000000..062ca0c --- /dev/null +++ b/src/main/java/com/jimuqu/claw/constitution/BlacklistInterceptor.java @@ -0,0 +1,131 @@ +package com.jimuqu.claw.constitution; + +import com.jimuqu.claw.config.props.BlacklistProperties; +import org.noear.solon.ai.agent.react.ReActTrace; +import org.noear.solon.ai.agent.react.intercept.HITLInterceptor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.regex.Pattern; + +/** + * 黑名单拦截器。 + * + *

实现 {@link HITLInterceptor.InterventionStrategy}, + * 在 bash 工具调用前检测命令是否命中黑名单。 + * 命中则直接返回拒绝消息,工具调用不会执行。 + * + *

所有黑名单条目均来自 app.yml 配置,Java 代码不包含任何默认值。 + */ +public class BlacklistInterceptor implements HITLInterceptor.InterventionStrategy { + private static final Logger LOG = LoggerFactory.getLogger(BlacklistInterceptor.class); + + /** 命令关键词黑名单(首个 token 匹配)。 */ + private final Set commandBlacklist = new HashSet<>(); + /** 路径黑名单(命令中包含这些路径片段)。 */ + private final List pathBlacklist = new ArrayList<>(); + /** 正则模式黑名单(整条命令匹配)。 */ + private final List patternBlacklist = new ArrayList<>(); + + public BlacklistInterceptor(BlacklistProperties config) { + if (config != null) { + for (String cmd : config.getExtraCommands()) { + if (cmd != null && !cmd.trim().isEmpty()) { + commandBlacklist.add(cmd.trim().toLowerCase()); + } + } + for (String path : config.getExtraPaths()) { + if (path != null && !path.trim().isEmpty()) { + pathBlacklist.add(path.trim()); + } + } + for (String pattern : config.getExtraPatterns()) { + if (pattern != null && !pattern.trim().isEmpty()) { + patternBlacklist.add(Pattern.compile(pattern)); + } + } + } + + LOG.info("Blacklist loaded: {} commands, {} paths, {} patterns", + commandBlacklist.size(), pathBlacklist.size(), patternBlacklist.size()); + } + + @Override + public String evaluate(ReActTrace trace, Map args) { + String cmd = (String) args.get("command"); + if (cmd == null || cmd.trim().isEmpty()) return null; + + cmd = cmd.trim(); + String reason; + + // ① 命令关键词 + reason = checkCommand(cmd); + if (reason != null) { + LOG.warn("[黑名单] 命令被拦截: {} ({})", cmd, reason); + return formatRejection(cmd, reason); + } + + // ② 路径 + reason = checkPath(cmd); + if (reason != null) { + LOG.warn("[黑名单] 路径被拦截: {} ({})", cmd, reason); + return formatRejection(cmd, reason); + } + + // ③ 正则模式 + reason = checkPattern(cmd); + if (reason != null) { + LOG.warn("[黑名单] 模式被拦截: {} ({})", cmd, reason); + return formatRejection(cmd, reason); + } + + return null; // 放行 + } + + // ===== 检查方法 ===== + + private String checkCommand(String cmd) { + // 提取第一个 token + String firstToken = cmd.split("\\s+")[0].toLowerCase(); + // 处理路径前缀,如 /usr/bin/sudo → sudo + int lastSlash = firstToken.lastIndexOf('/'); + if (lastSlash >= 0 && lastSlash < firstToken.length() - 1) { + firstToken = firstToken.substring(lastSlash + 1); + } + int lastBackSlash = firstToken.lastIndexOf('\\'); + if (lastBackSlash >= 0 && lastBackSlash < firstToken.length() - 1) { + firstToken = firstToken.substring(lastBackSlash + 1); + } + + if (commandBlacklist.contains(firstToken)) { + return "命令 \"" + firstToken + "\" 被禁止"; + } + return null; + } + + private String checkPath(String cmd) { + String normalized = cmd.replace('\\', '/'); + for (String forbidden : pathBlacklist) { + String normalizedForbidden = forbidden.replace('\\', '/'); + if (normalized.contains(normalizedForbidden)) { + return "路径 \"" + forbidden + "\" 被禁止访问"; + } + } + return null; + } + + private String checkPattern(String cmd) { + for (Pattern p : patternBlacklist) { + if (p.matcher(cmd).matches()) { + return "命令匹配到危险模式"; + } + } + return null; + } + + private String formatRejection(String cmd, String reason) { + String summary = cmd.length() > 80 ? cmd.substring(0, 77) + "..." : cmd; + return "[系统安全] 此命令被禁止执行: " + summary + "\n原因: " + reason; + } +} diff --git a/src/main/resources/template/GOVERNANCE.md b/src/main/resources/template/GOVERNANCE.md new file mode 100644 index 0000000..55347e6 --- /dev/null +++ b/src/main/resources/template/GOVERNANCE.md @@ -0,0 +1,69 @@ +# 治理规则 + +你拥有尽可能大的操作权限。系统层面只有极少数命令被硬性禁止(你会收到拒绝消息)。除此之外,一切由你根据当前上下文自主判断。 + +## 主动确认 + +以下类型的操作,建议你在执行前**主动**向用户确认: + +- 删除文件或目录(尤其是多个文件、或非临时文件) +- 安装或卸载系统级包(`npm install -g`、`pip install`、`apt install` 等) +- 修改环境配置文件(`.env`、`config.yml`、`.bashrc`、`.profile` 等) +- 向外部服务发送非只读请求(`curl -X POST`、webhook、API 写操作等) +- 执行可能耗时超过 30 秒的命令 +- 对工作区外的路径进行写操作 +- 你自己不确定是否安全的命令 + +确认方式:在当前对话中自然语言询问,例如: + +> "我需要执行 `npm install express`,可以继续吗?" + +如果用户的消息本身就是明确的指令(如"帮我安装 express"),则不需要再确认——直接执行。 + +## 授权记忆 + +当用户明确批准某类操作时,判断记忆层级: + +### 临时授权 + +- 条件:用户说"可以"、"好的"、"执行吧"等简单确认 +- 处理:只在当前对话中生效,不写入任何文件 +- 下次对话需重新确认 + +### 短期授权 + +- 条件:用户说"今天都可以"、"这类操作不用再问了" +- 处理:写入 `memory/YYYY-MM-DD.md` +- 格式:`[授权] <操作类型> - 已授权 (YYYY-MM-DD)` +- 到期后自然失效 + +### 长期授权 + +- 条件:用户说"以后都不用问"、"永远允许"、"记住这个" +- 处理:写入 `MEMORY.md` +- 格式:`[长期授权] <操作类型> - 永久允许 (YYYY-MM-DD)` + +### 拿不准时 + +- 默认当作临时授权 +- 宁可多问一次,不要擅自升级授权层级 + +## 危险经验 + +当命令执行失败或产生意外后果时: + +1. 记录到当天 `memory/YYYY-MM-DD.md`: + `[危险] <命令> - <后果描述>` + +2. 如果是严重教训,写入 `MEMORY.md`: + `[教训] <经验总结> (YYYY-MM-DD)` + +3. 下次遇到类似操作时,主动提醒用户上次出过问题 + +## 总体原则 + +1. 大胆执行用户明确要求的操作 +2. 只有在"可能造成不可逆后果"时主动确认 +3. 如果用户明确要求,即使你觉得有风险也要先做(除非系统黑名单拦截) +4. 认真阅读 `MEMORY.md` 和近期 `memory/*.md` 中的授权与教训记录 +5. 不确定时,问一句的代价远小于搞砸的代价 diff --git a/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java b/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java deleted file mode 100644 index 44d9626..0000000 --- a/src/test/java/com/jimuqu/claw/agent/job/WorkspaceJobServiceTest.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.jimuqu.claw.agent.job; - -import com.jimuqu.claw.agent.model.ChannelType; -import com.jimuqu.claw.agent.model.ConversationType; -import com.jimuqu.claw.agent.model.ReplyTarget; -import com.jimuqu.claw.agent.store.RuntimeStoreService; -import com.jimuqu.claw.agent.workspace.AgentWorkspaceService; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.noear.solon.scheduling.annotation.Scheduled; -import org.noear.solon.scheduling.scheduled.JobHandler; -import org.noear.solon.scheduling.scheduled.JobHolder; -import org.noear.solon.scheduling.scheduled.JobInterceptor; -import org.noear.solon.scheduling.scheduled.manager.IJobManager; - -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class WorkspaceJobServiceTest { - @Test - void onceDelayUsesScheduleValueAsInitialDelayWhenInitialDelayIsZero(@TempDir Path tempDir) { - Fixture fixture = new Fixture(tempDir); - - fixture.service.addJob( - "weather-once", - "once_delay", - "60000", - "1分钟后再次汇报重庆天气", - 0L, - "" - ); - - JobHolder holder = fixture.jobManager.jobGet("weather-once"); - assertNotNull(holder); - assertEquals(60000L, holder.getScheduled().initialDelay()); - assertEquals(60000L, holder.getScheduled().fixedDelay()); - } - - @Test - void triggeredJobWrapsPromptAsImmediateExecutionInstruction(@TempDir Path tempDir) throws Throwable { - Fixture fixture = new Fixture(tempDir); - List dispatched = new ArrayList<>(); - fixture.service.setJobDispatcher((sessionKey, replyTarget, prompt) -> { - dispatched.add(sessionKey); - dispatched.add(replyTarget.getConversationId()); - dispatched.add(prompt); - return "run-1"; - }); - - fixture.service.addJob( - "weather-once", - "once_delay", - "60000", - "1分钟后再次汇报重庆天气", - 0L, - "" - ); - - fixture.jobManager.trigger("weather-once"); - - assertEquals(3, dispatched.size()); - assertEquals("dingtalk:group:group-1", dispatched.get(0)); - assertEquals("group-1", dispatched.get(1)); - assertTrue(dispatched.get(2).contains("[定时任务触发]")); - assertTrue(dispatched.get(2).contains("现在立刻完成下面的任务")); - assertTrue(dispatched.get(2).contains("不要把这条消息再理解成“稍后执行”")); - assertTrue(dispatched.get(2).contains("1分钟后再次汇报重庆天气")); - assertTrue(!fixture.jobManager.jobExists("weather-once")); - } - - private static final class Fixture { - private final FakeJobManager jobManager = new FakeJobManager(); - private final WorkspaceJobService service; - - private Fixture(Path tempDir) { - AgentWorkspaceService workspaceService = new AgentWorkspaceService(tempDir.toString()); - JobStoreService jobStoreService = new JobStoreService(workspaceService); - RuntimeStoreService runtimeStoreService = new RuntimeStoreService(workspaceService.fileInWorkspace("runtime")); - runtimeStoreService.rememberReplyTarget( - "dingtalk:group:group-1", - new ReplyTarget(ChannelType.DINGTALK, ConversationType.GROUP, "group-1", "user-1") - ); - this.service = new WorkspaceJobService(jobManager, jobStoreService, runtimeStoreService); - this.service.setJobDispatcher((sessionKey, replyTarget, prompt) -> "run-0"); - } - } - - private static final class FakeJobManager implements IJobManager { - private final Map jobs = new LinkedHashMap<>(); - - @Override - public void addJobInterceptor(int index, JobInterceptor interceptor) { - } - - @Override - public boolean hasJobInterceptor() { - return false; - } - - @Override - public List> getJobInterceptors() { - return new ArrayList<>(); - } - - @Override - public JobHolder jobAdd(String name, Scheduled scheduled, JobHandler handler) { - return jobAdd(name, scheduled, handler, null); - } - - @Override - public JobHolder jobAdd(String name, Scheduled scheduled, JobHandler handler, Map data) { - JobHolder holder = new JobHolder(this, name, scheduled, handler); - holder.setData(data); - jobs.put(name, holder); - return holder; - } - - @Override - public boolean jobExists(String name) { - return jobs.containsKey(name); - } - - @Override - public JobHolder jobGet(String name) { - return jobs.get(name); - } - - @Override - public Map jobGetAll() { - return jobs; - } - - @Override - public void jobRemove(String name) { - jobs.remove(name); - } - - @Override - public void jobStart(String name, Map data) { - } - - @Override - public void jobStop(String name) { - } - - @Override - public boolean isStarted() { - return false; - } - - @Override - public void start() { - } - - public void trigger(String name) throws Throwable { - JobHolder holder = jobs.get(name); - if (holder != null) { - holder.getHandler().handle(null); - } - } - } -} diff --git a/src/test/java/com/jimuqu/claw/constitution/BlacklistInterceptorTest.java b/src/test/java/com/jimuqu/claw/constitution/BlacklistInterceptorTest.java new file mode 100644 index 0000000..8c53b5d --- /dev/null +++ b/src/test/java/com/jimuqu/claw/constitution/BlacklistInterceptorTest.java @@ -0,0 +1,119 @@ +package com.jimuqu.claw.constitution; + +import com.jimuqu.claw.config.props.BlacklistProperties; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 验证黑名单拦截器的匹配规则。 + */ +class BlacklistInterceptorTest { + + // ===== 命令关键词 ===== + + @Test + void blocksBlacklistedCommand() { + BlacklistProperties props = new BlacklistProperties(); + props.setExtraCommands(Arrays.asList("sudo", "reboot", "shutdown")); + BlacklistInterceptor interceptor = new BlacklistInterceptor(props); + + assertNotNull(interceptor.evaluate(null, args("sudo apt install nginx"))); + assertNotNull(interceptor.evaluate(null, args("reboot"))); + assertNotNull(interceptor.evaluate(null, args("shutdown -h now"))); + } + + @Test + void allowsNonBlacklistedCommand() { + BlacklistProperties props = new BlacklistProperties(); + props.setExtraCommands(Collections.singletonList("sudo")); + BlacklistInterceptor interceptor = new BlacklistInterceptor(props); + + assertNull(interceptor.evaluate(null, args("ls -la"))); + assertNull(interceptor.evaluate(null, args("cat README.md"))); + assertNull(interceptor.evaluate(null, args("pwd"))); + assertNull(interceptor.evaluate(null, args("npm install express"))); + } + + // ===== 路径 ===== + + @Test + void blocksBlacklistedPath() { + BlacklistProperties props = new BlacklistProperties(); + props.setExtraPaths(Arrays.asList("/etc/shadow", "/.ssh/")); + BlacklistInterceptor interceptor = new BlacklistInterceptor(props); + + assertNotNull(interceptor.evaluate(null, args("cat /etc/shadow"))); + assertNotNull(interceptor.evaluate(null, args("ls ~/.ssh/id_rsa"))); + } + + @Test + void allowsNonBlacklistedPath() { + BlacklistProperties props = new BlacklistProperties(); + props.setExtraPaths(Collections.singletonList("/etc/shadow")); + BlacklistInterceptor interceptor = new BlacklistInterceptor(props); + + assertNull(interceptor.evaluate(null, args("cat /etc/hosts"))); + assertNull(interceptor.evaluate(null, args("ls /home/user/"))); + } + + // ===== 正则模式 ===== + + @Test + void blocksBlacklistedPattern() { + BlacklistProperties props = new BlacklistProperties(); + props.setExtraPatterns(Arrays.asList( + ".*\\brm\\s+-rf\\s+/\\s*$", + "(?i).*\\bformat\\s+[A-Z]:.*" + )); + BlacklistInterceptor interceptor = new BlacklistInterceptor(props); + + assertNotNull(interceptor.evaluate(null, args("rm -rf /"))); + assertNotNull(interceptor.evaluate(null, args("FORMAT C:"))); + } + + @Test + void allowsNonMatchingPattern() { + BlacklistProperties props = new BlacklistProperties(); + props.setExtraPatterns(Collections.singletonList(".*\\brm\\s+-rf\\s+/\\s*$")); + BlacklistInterceptor interceptor = new BlacklistInterceptor(props); + + assertNull(interceptor.evaluate(null, args("rm -rf ./node_modules"))); + assertNull(interceptor.evaluate(null, args("rm file.txt"))); + } + + // ===== 禁用时 ===== + + @Test + void allowsEverythingWhenDisabled() { + BlacklistInterceptor interceptor = new BlacklistInterceptor(null); + + assertNull(interceptor.evaluate(null, args("sudo rm -rf /"))); + assertNull(interceptor.evaluate(null, args("cat /etc/shadow"))); + } + + // ===== 空/null 命令 ===== + + @Test + void allowsEmptyAndNullCommand() { + BlacklistProperties props = new BlacklistProperties(); + props.setExtraCommands(Collections.singletonList("sudo")); + BlacklistInterceptor interceptor = new BlacklistInterceptor(props); + + assertNull(interceptor.evaluate(null, args(""))); + assertNull(interceptor.evaluate(null, args(null))); + } + + // ===== 辅助方法 ===== + + private Map args(String command) { + Map map = new HashMap<>(); + map.put("command", command); + return map; + } +} -- Gitee