From 090bb64c6ad844ea71041de38ba7c0c9bb253606 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 20 May 2026 12:29:29 +0800 Subject: [PATCH] sync: Feishu channel-native tool provider + DbRuleGuardian generic guard --- .../tool/FeishuChannelToolProvider.java | 211 ++++++++++++++++++ .../feishu/tool/FeishuToolCatalog.java | 99 ++++++++ .../mate/channel/tool/ChannelToolService.java | 67 +++++- .../tool/guard/guardian/DbRuleGuardian.java | 109 +++++++++ .../guard/guardian/ShellCommandGuardian.java | 10 +- .../feishu/tool/FeishuToolCatalogTest.java | 59 +++++ .../guard/guardian/DbRuleGuardianTest.java | 111 +++++++++ 7 files changed, 664 insertions(+), 2 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/tool/FeishuChannelToolProvider.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/tool/FeishuToolCatalog.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/DbRuleGuardian.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/tool/FeishuToolCatalogTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/DbRuleGuardianTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/tool/FeishuChannelToolProvider.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/tool/FeishuChannelToolProvider.java new file mode 100644 index 00000000..8a75d88c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/tool/FeishuChannelToolProvider.java @@ -0,0 +1,211 @@ +package vip.mate.channel.feishu.tool; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lark.oapi.Client; +import com.lark.oapi.service.calendar.v4.model.CalendarEvent; +import com.lark.oapi.service.calendar.v4.model.ListCalendarEventReq; +import com.lark.oapi.service.calendar.v4.model.ListCalendarEventResp; +import com.lark.oapi.service.docx.v1.model.CreateDocumentReq; +import com.lark.oapi.service.docx.v1.model.CreateDocumentReqBody; +import com.lark.oapi.service.docx.v1.model.CreateDocumentResp; +import com.lark.oapi.service.docx.v1.model.RawContentDocumentReq; +import com.lark.oapi.service.docx.v1.model.RawContentDocumentResp; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.stereotype.Component; +import vip.mate.channel.feishu.FeishuClientFactory; +import vip.mate.channel.tool.ChannelToolCallback; +import vip.mate.channel.tool.ChannelToolContext; +import vip.mate.channel.tool.ChannelToolDescriptor; +import vip.mate.channel.tool.ChannelToolProvider; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * First concrete {@link ChannelToolProvider} — exposes a representative + * subset of Feishu's OpenAPI as Agent tools: + * + * + * + *

Both reads ship default-enabled; the write ships default-disabled + * and {@code ChannelToolService} seeds a HIGH-severity guard rule so a + * call falls into {@code NEEDS_APPROVAL} via {@code DbRuleGuardian}. + * + *

Tool I/O uses JSON — input is the tool's argument JSON string, + * output is a compact JSON result (success or {@code "error"} key). + * Returning a structured object rather than free text keeps the LLM + * downstream branch-friendly. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FeishuChannelToolProvider implements ChannelToolProvider { + + private final FeishuClientFactory clientFactory; + private final ObjectMapper objectMapper; + + @Override + public String channelType() { + return "feishu"; + } + + @Override + public List describeTools() { + return FeishuToolCatalog.descriptors(); + } + + @Override + public List createTools(ChannelToolContext context) { + Long channelId = context.channelId(); + List out = new ArrayList<>(3); + + for (ChannelToolDescriptor d : FeishuToolCatalog.descriptors()) { + out.add(new ChannelToolCallback( + d.name(), d.description(), d.inputSchema(), + input -> dispatch(d.name(), channelId, input))); + } + return out; + } + + /** Route by tool name. Kept in one place so the descriptor catalog drives the surface. */ + private String dispatch(String toolName, Long channelId, String input) { + try { + Client client = clientFactory.client(channelId); + return switch (toolName) { + case FeishuToolCatalog.TOOL_LIST_EVENTS -> handleListEvents(client, input); + case FeishuToolCatalog.TOOL_DOC_READ -> handleDocRead(client, input); + case FeishuToolCatalog.TOOL_DOC_CREATE -> handleDocCreate(client, input); + default -> errorJson("Unknown Feishu tool: " + toolName); + }; + } catch (Exception e) { + log.warn("[feishu-tool] {} failed: {}", toolName, e.getMessage()); + return errorJson(e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); + } + } + + // ------------------------------------------------------------------ + // Handlers + // ------------------------------------------------------------------ + + private String handleListEvents(Client client, String input) throws Exception { + JsonNode args = objectMapper.readTree(input == null || input.isBlank() ? "{}" : input); + String calendarId = textArg(args, "calendar_id"); + if (calendarId == null) return errorJson("calendar_id is required"); + + ListCalendarEventReq.Builder req = ListCalendarEventReq.newBuilder().calendarId(calendarId); + String startTime = textArg(args, "start_time"); + String endTime = textArg(args, "end_time"); + Integer pageSize = intArg(args, "page_size"); + if (startTime != null) req.startTime(startTime); + if (endTime != null) req.endTime(endTime); + if (pageSize != null) req.pageSize(pageSize); + + ListCalendarEventResp resp = client.calendar().v4().calendarEvent().list(req.build()); + if (!resp.success() || resp.getData() == null) { + return errorJson("calendar list failed: code=" + resp.getCode() + ", msg=" + resp.getMsg()); + } + CalendarEvent[] items = resp.getData().getItems(); + Map result = new LinkedHashMap<>(); + result.put("count", items == null ? 0 : items.length); + // Compact representation — title + start + end keeps the LLM context tight. + List> events = new ArrayList<>(); + if (items != null) { + for (CalendarEvent ev : items) { + Map e = new LinkedHashMap<>(); + e.put("event_id", ev.getEventId()); + e.put("summary", ev.getSummary()); + if (ev.getStartTime() != null) e.put("start", ev.getStartTime().getTimestamp()); + if (ev.getEndTime() != null) e.put("end", ev.getEndTime().getTimestamp()); + events.add(e); + } + } + result.put("events", events); + return objectMapper.writeValueAsString(result); + } + + private String handleDocRead(Client client, String input) throws Exception { + JsonNode args = objectMapper.readTree(input == null || input.isBlank() ? "{}" : input); + String documentId = textArg(args, "document_id"); + if (documentId == null) return errorJson("document_id is required"); + + RawContentDocumentReq req = RawContentDocumentReq.newBuilder().documentId(documentId).build(); + RawContentDocumentResp resp = client.docx().v1().document().rawContent(req); + if (!resp.success() || resp.getData() == null) { + return errorJson("doc read failed: code=" + resp.getCode() + ", msg=" + resp.getMsg()); + } + Map result = new LinkedHashMap<>(); + result.put("document_id", documentId); + result.put("content", resp.getData().getContent()); + return objectMapper.writeValueAsString(result); + } + + private String handleDocCreate(Client client, String input) throws Exception { + JsonNode args = objectMapper.readTree(input == null || input.isBlank() ? "{}" : input); + String title = textArg(args, "title"); + if (title == null) return errorJson("title is required"); + String folderToken = textArg(args, "folder_token"); + + CreateDocumentReqBody.Builder body = CreateDocumentReqBody.newBuilder().title(title); + if (folderToken != null && !folderToken.isBlank()) { + body.folderToken(folderToken); + } + CreateDocumentReq req = CreateDocumentReq.newBuilder() + .createDocumentReqBody(body.build()) + .build(); + CreateDocumentResp resp = client.docx().v1().document().create(req); + if (!resp.success() || resp.getData() == null || resp.getData().getDocument() == null) { + return errorJson("doc create failed: code=" + resp.getCode() + ", msg=" + resp.getMsg()); + } + String docId = resp.getData().getDocument().getDocumentId(); + Map result = new LinkedHashMap<>(); + result.put("document_id", docId); + result.put("revision_id", resp.getData().getDocument().getRevisionId()); + result.put("title", title); + // The URL is constructed client-side; SDK doesn't return it. + return objectMapper.writeValueAsString(result); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private String textArg(JsonNode args, String key) { + if (args == null) return null; + JsonNode n = args.get(key); + if (n == null || n.isNull()) return null; + String v = n.asText(""); + return v.isBlank() ? null : v; + } + + private Integer intArg(JsonNode args, String key) { + if (args == null) return null; + JsonNode n = args.get(key); + if (n == null || n.isNull()) return null; + if (n.canConvertToInt()) return n.intValue(); + try { + return Integer.parseInt(n.asText()); + } catch (NumberFormatException e) { + return null; + } + } + + private String errorJson(String message) { + try { + return objectMapper.writeValueAsString(Map.of("error", message)); + } catch (Exception e) { + return "{\"error\":\"" + message.replace("\"", "\\\"") + "\"}"; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/tool/FeishuToolCatalog.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/tool/FeishuToolCatalog.java new file mode 100644 index 00000000..b1b2f49f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/tool/FeishuToolCatalog.java @@ -0,0 +1,99 @@ +package vip.mate.channel.feishu.tool; + +import vip.mate.channel.tool.ChannelToolDescriptor; + +import java.util.List; + +/** + * Static catalog of Feishu channel-native tools. Kept separate from + * the provider so the descriptors can be queried by tests / docs + * without instantiating the provider (which would drag in the SDK + * client factory). + * + *

Tool naming: the base name registered here is the "human" + * identifier. {@code ChannelToolService} appends {@code _c} + * before registering with {@code ToolRegistry}, so the actual name an + * Agent sees is e.g. {@code feishu_doc_create_c2055137662148763649}. + * + *

Initial set covers the most useful read + a representative write + * per resource family — the rest land as follow-ups: + *

+ */ +public final class FeishuToolCatalog { + + public static final String TOOL_LIST_EVENTS = "feishu_calendar_list_events"; + public static final String TOOL_DOC_READ = "feishu_doc_read"; + public static final String TOOL_DOC_CREATE = "feishu_doc_create"; + + private FeishuToolCatalog() {} + + public static List descriptors() { + return List.of( + new ChannelToolDescriptor( + TOOL_LIST_EVENTS, + "List Feishu calendar events", + "List events on a Feishu calendar within a time window. " + + "Required: calendar_id (the user's primary calendar id is usually returned by " + + "the calendar.primary endpoint). Optional: start_time (UNIX seconds string), " + + "end_time (UNIX seconds string), page_size (1-1000, default 100).", + eventsListSchema(), + /* mutating */ false, /* enabledByDefault */ true), + + new ChannelToolDescriptor( + TOOL_DOC_READ, + "Read a Feishu Doc as plain text", + "Fetch a Feishu Doc's raw plain-text content. Required: document_id " + + "(the {documentId} segment in the URL https://x.feishu.cn/docx/{documentId}). " + + "Returns the raw concatenated text — no formatting / images / tables.", + docReadSchema(), + /* mutating */ false, /* enabledByDefault */ true), + + new ChannelToolDescriptor( + TOOL_DOC_CREATE, + "Create a new Feishu Doc", + "Create an empty Feishu Doc. Required: title (string). Optional: folder_token " + + "(target folder; empty string = root). Returns the new doc's " + + "{document_id, url}. NOTE: only the bot's app sees the new doc until " + + "you explicitly share it — pass owner_open_id later via a permission " + + "tool to grant access. This is a write tool and triggers an approval.", + docCreateSchema(), + /* mutating */ true, /* enabledByDefault */ false) + ); + } + + // ------------------------------------------------------------------ + // JSON Schemas — kept as constants so the descriptor is pure data + // ------------------------------------------------------------------ + + private static String eventsListSchema() { + return "{" + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"calendar_id\":{\"type\":\"string\",\"description\":\"target calendar id\"}," + + "\"start_time\":{\"type\":\"string\",\"description\":\"UNIX seconds, lower bound\"}," + + "\"end_time\":{\"type\":\"string\",\"description\":\"UNIX seconds, upper bound\"}," + + "\"page_size\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":1000,\"default\":100}" + + "},\"required\":[\"calendar_id\"]}"; + } + + private static String docReadSchema() { + return "{" + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"document_id\":{\"type\":\"string\",\"description\":\"the {documentId} segment of the doc URL\"}" + + "},\"required\":[\"document_id\"]}"; + } + + private static String docCreateSchema() { + return "{" + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"title\":{\"type\":\"string\",\"description\":\"document title\"}," + + "\"folder_token\":{\"type\":\"string\",\"description\":\"target folder token; empty = root\"}" + + "},\"required\":[\"title\"]}"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolService.java b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolService.java index 2a9a2ad2..f103e50d 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolService.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/tool/ChannelToolService.java @@ -12,6 +12,9 @@ import org.springframework.stereotype.Component; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.repository.ChannelMapper; import vip.mate.tool.ToolRegistry; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; +import vip.mate.tool.guard.model.ToolGuardRuleEntity; +import vip.mate.tool.guard.repository.ToolGuardRuleMapper; import vip.mate.tool.model.ToolEntity; import vip.mate.tool.repository.ToolMapper; @@ -68,6 +71,8 @@ public class ChannelToolService { private final ToolMapper toolMapper; private final ToolRegistry toolRegistry; private final ObjectMapper objectMapper; + private final ToolGuardRuleMapper guardRuleMapper; + private final ToolGuardRuleRegistry guardRuleRegistry; /** Indexed at startup: channelType → provider. */ private Map providersByType = Map.of(); @@ -89,12 +94,16 @@ public class ChannelToolService { ChannelMapper channelMapper, ToolMapper toolMapper, ToolRegistry toolRegistry, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + ToolGuardRuleMapper guardRuleMapper, + ToolGuardRuleRegistry guardRuleRegistry) { this.providerBeans = providerBeans != null ? providerBeans : List.of(); this.channelMapper = channelMapper; this.toolMapper = toolMapper; this.toolRegistry = toolRegistry; this.objectMapper = objectMapper; + this.guardRuleMapper = guardRuleMapper; + this.guardRuleRegistry = guardRuleRegistry; } @PostConstruct @@ -211,6 +220,7 @@ public class ChannelToolService { return; } Map nameMap = upsertToolRows(ch, descriptors); + seedGuardRules(descriptors, nameMap); ChannelToolContext context = new ChannelToolContext( ch.getId(), ch.getName(), ch.getChannelType(), ch.getAgentId(), @@ -299,6 +309,61 @@ public class ChannelToolService { return nameMap; } + /** + * Seed one HIGH-severity DB rule per mutating descriptor so the + * tool's invocation gets evaluated by {@code DbRuleGuardian} → + * {@code NEEDS_APPROVAL}. The rule pattern is {@code ".*"} so + * every invocation matches; the severity is what drives the + * approval decision, not pattern specificity. + * + *

Idempotent: a stable {@code rule_id} per (tool, channel) + + * {@code ON DUPLICATE KEY UPDATE}-style upsert keeps re-reconcile + * safe. Triggers a registry reload so the new rule is immediately + * visible to the next invocation. + */ + private void seedGuardRules(List descriptors, Map nameMap) { + boolean changed = false; + for (ChannelToolDescriptor d : descriptors) { + if (!d.mutating()) continue; + String actualName = nameMap.get(d.name()); + if (actualName == null) continue; + String ruleId = "channel_tool:" + actualName; + ToolGuardRuleEntity existing = guardRuleMapper.selectOne( + new LambdaQueryWrapper().eq(ToolGuardRuleEntity::getRuleId, ruleId)); + if (existing != null) continue; // already seeded — never override user edits + ToolGuardRuleEntity row = new ToolGuardRuleEntity(); + row.setRuleId(ruleId); + row.setName("Channel write tool — approval required"); + row.setDescription("Auto-seeded approval gate for channel-native write tool " + actualName); + row.setToolName(actualName); + row.setParamName("args"); + row.setCategory("SENSITIVE_FILE_ACCESS"); + row.setSeverity("HIGH"); + row.setDecision("NEEDS_APPROVAL"); + row.setPattern(".*"); // every invocation matches + row.setRemediation("Confirm the requested change is intended, then approve."); + row.setBuiltin(false); + row.setEnabled(true); + row.setPriority(100); + try { + guardRuleMapper.insert(row); + changed = true; + log.info("[channel-tool] Seeded approval rule for write tool {}", actualName); + } catch (org.springframework.dao.DuplicateKeyException race) { + // Another node beat us to it — the existing row is fine. + } catch (Exception e) { + log.warn("[channel-tool] Failed to seed guard rule for {}: {}", actualName, e.getMessage()); + } + } + if (changed) { + try { + guardRuleRegistry.reload(); + } catch (Exception e) { + log.debug("[channel-tool] guard rule reload failed (non-fatal): {}", e.getMessage()); + } + } + } + private void deleteToolRows(Long channelId) { int deleted = toolMapper.delete( new LambdaQueryWrapper().eq(ToolEntity::getChannelId, channelId)); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/DbRuleGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/DbRuleGuardian.java new file mode 100644 index 00000000..c9e83f3e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/DbRuleGuardian.java @@ -0,0 +1,109 @@ +package vip.mate.tool.guard.guardian; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; +import vip.mate.tool.guard.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Generic guardian — applies any DB-stored {@link ToolGuardRuleEntity} + * to the matching tool invocation, regardless of tool type. Previously + * the only consumer of DB rules was {@link ShellCommandGuardian}, which + * gated on a hard-coded list of 3 shell tool names; that left every + * other tool (including channel-native ones from + * {@code ChannelToolProvider}) with no path from a {@code + * mate_tool_guard_rule} row to a Guard finding. This class fixes that + * gap. + * + *

Disjointness contract: {@link ShellCommandGuardian} now + * defers to this class when a shell tool has DB rules — see its + * {@code supports()} gate. So a single invocation is evaluated by + * exactly one of the two, never both. Pattern matching is identical + * between them, preserving the existing test corpus. + */ +@Slf4j +@Component +public class DbRuleGuardian implements ToolGuardGuardian { + + private final ToolGuardRuleRegistry ruleRegistry; + + public DbRuleGuardian(ToolGuardRuleRegistry ruleRegistry) { + this.ruleRegistry = ruleRegistry; + } + + @Override + public boolean supports(ToolInvocationContext context) { + return context != null && context.toolName() != null + && !ruleRegistry.getRulesForTool(context.toolName()).isEmpty(); + } + + /** + * Priority just below {@link ShellCommandGuardian}'s 200 so when + * the engine sorts guardians, both will fire in a stable order + * before any built-in-rule fallbacks. They're disjoint via + * {@code supports()} so the order is cosmetic. + */ + @Override + public int priority() { + return 199; + } + + @Override + public List evaluate(ToolInvocationContext context) { + String combined = buildMatchInput(context); + if (combined == null || combined.isEmpty()) return List.of(); + + List findings = new ArrayList<>(); + for (ToolGuardRuleEntity rule : ruleRegistry.getRulesForTool(context.toolName())) { + Pattern pattern = ruleRegistry.getCompiledPattern(rule.getPattern()); + Matcher matcher = pattern.matcher(combined); + if (!matcher.find()) continue; + + if (rule.getExcludePattern() != null && !rule.getExcludePattern().isBlank()) { + Pattern exclude = ruleRegistry.getCompiledExcludePattern(rule.getExcludePattern()); + if (exclude.matcher(combined).find()) continue; + } + String snippet = extractSnippet(combined, matcher.start(), 40); + findings.add(new GuardFinding( + rule.getRuleId(), + GuardSeverity.valueOf(rule.getSeverity()), + GuardCategory.valueOf(rule.getCategory()), + rule.getName(), + rule.getDescription(), + rule.getRemediation(), + context.toolName(), + rule.getParamName() != null ? rule.getParamName() : "args", + rule.getPattern(), + snippet, + parseDecision(rule.getDecision()) + )); + } + return findings; + } + + private static String buildMatchInput(ToolInvocationContext context) { + String raw = context.rawArguments(); + if (raw == null || raw.isEmpty()) return null; + return (context.toolName() != null ? context.toolName() + " " : "") + raw; + } + + private static GuardDecision parseDecision(String raw) { + if (raw == null || raw.isBlank()) return null; + try { + return GuardDecision.valueOf(raw); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String extractSnippet(String input, int matchStart, int contextLen) { + int start = Math.max(0, matchStart - contextLen / 2); + int end = Math.min(input.length(), matchStart + contextLen / 2); + return input.substring(start, end); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java index 1276765c..06e7b2a8 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/guardian/ShellCommandGuardian.java @@ -39,7 +39,15 @@ public class ShellCommandGuardian implements ToolGuardGuardian { @Override public boolean supports(ToolInvocationContext context) { - return context.toolName() != null && SHELL_TOOL_NAMES.contains(context.toolName()); + if (context.toolName() == null || !SHELL_TOOL_NAMES.contains(context.toolName())) { + return false; + } + // Mutual-exclusion gate with DbRuleGuardian: when DB rules + // exist for this shell tool, DbRuleGuardian evaluates them + // and we skip — keeping the two paths strictly disjoint. + // Empty DB rules → we own this invocation and fall through to + // the hard-coded built-in shell rules below. + return ruleRegistry.getRulesForTool(context.toolName()).isEmpty(); } @Override diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/tool/FeishuToolCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/tool/FeishuToolCatalogTest.java new file mode 100644 index 00000000..098cd139 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/tool/FeishuToolCatalogTest.java @@ -0,0 +1,59 @@ +package vip.mate.channel.feishu.tool; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.tool.ChannelToolDescriptor; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the descriptor catalog: read tools default on, write tools land + * disabled, names are stable (downstream rule IDs and channel-tool + * UI state bind on them). + */ +class FeishuToolCatalogTest { + + @Test + @DisplayName("catalog returns the agreed 3-tool initial set") + void catalogShape() { + List ds = FeishuToolCatalog.descriptors(); + Set names = ds.stream().map(ChannelToolDescriptor::name).collect(java.util.stream.Collectors.toSet()); + assertEquals(Set.of( + FeishuToolCatalog.TOOL_LIST_EVENTS, + FeishuToolCatalog.TOOL_DOC_READ, + FeishuToolCatalog.TOOL_DOC_CREATE + ), names); + } + + @Test + @DisplayName("read tools land default-enabled, write tool lands default-disabled") + void readWriteDefaults() { + Map byName = FeishuToolCatalog.descriptors().stream() + .collect(java.util.stream.Collectors.toMap(ChannelToolDescriptor::name, d -> d)); + + assertFalse(byName.get(FeishuToolCatalog.TOOL_LIST_EVENTS).mutating()); + assertTrue(byName.get(FeishuToolCatalog.TOOL_LIST_EVENTS).enabledByDefault()); + + assertFalse(byName.get(FeishuToolCatalog.TOOL_DOC_READ).mutating()); + assertTrue(byName.get(FeishuToolCatalog.TOOL_DOC_READ).enabledByDefault()); + + assertTrue(byName.get(FeishuToolCatalog.TOOL_DOC_CREATE).mutating()); + assertFalse(byName.get(FeishuToolCatalog.TOOL_DOC_CREATE).enabledByDefault(), + "write tools must be disabled by default so a freshly-installed channel doesn't auto-create docs"); + } + + @Test + @DisplayName("every descriptor carries a non-blank JSON schema") + void schemasNonBlank() { + for (ChannelToolDescriptor d : FeishuToolCatalog.descriptors()) { + assertTrue(d.inputSchema().contains("type"), + d.name() + " schema should look like JSON Schema; got: " + d.inputSchema()); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/DbRuleGuardianTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/DbRuleGuardianTest.java new file mode 100644 index 00000000..d3a34dc8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/guardian/DbRuleGuardianTest.java @@ -0,0 +1,111 @@ +package vip.mate.tool.guard.guardian; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; +import vip.mate.tool.guard.model.GuardCategory; +import vip.mate.tool.guard.model.GuardDecision; +import vip.mate.tool.guard.model.GuardFinding; +import vip.mate.tool.guard.model.GuardSeverity; +import vip.mate.tool.guard.model.ToolGuardRuleEntity; +import vip.mate.tool.guard.model.ToolInvocationContext; + +import java.util.List; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Pin the DbRuleGuardian contract: + *

    + *
  • supports(): true iff DB rules exist for the tool
  • + *
  • evaluate(): produces a finding when the rule pattern matches
  • + *
  • exclude pattern suppresses the finding
  • + *
  • rule decision carries through to GuardFinding.decision
  • + *
+ */ +class DbRuleGuardianTest { + + private static ToolGuardRuleEntity rule(String pattern, String severity, String decision, String exclude) { + ToolGuardRuleEntity r = new ToolGuardRuleEntity(); + r.setRuleId("rule-1"); + r.setToolName("feishu_doc_create_c1"); + r.setName("Approval gate"); + r.setDescription("desc"); + r.setRemediation("approve to proceed"); + r.setParamName("args"); + r.setCategory(GuardCategory.SENSITIVE_FILE_ACCESS.name()); + r.setSeverity(severity); + r.setDecision(decision); + r.setPattern(pattern); + r.setExcludePattern(exclude); + r.setEnabled(true); + r.setPriority(100); + return r; + } + + private static ToolInvocationContext ctx(String toolName, String args) { + return ToolInvocationContext.of(toolName, args, null, null); + } + + @Test + @DisplayName("supports() returns false when registry has no rules for the tool") + void supportsOnlyWhenRulesPresent() { + ToolGuardRuleRegistry reg = mock(ToolGuardRuleRegistry.class); + when(reg.getRulesForTool(any())).thenReturn(List.of()); + DbRuleGuardian g = new DbRuleGuardian(reg); + assertFalse(g.supports(ctx("feishu_doc_create_c1", "{}"))); + } + + @Test + @DisplayName("supports() returns true and matching .* pattern produces a finding") + void evaluateMatchingPatternProducesFinding() { + ToolGuardRuleRegistry reg = mock(ToolGuardRuleRegistry.class); + ToolGuardRuleEntity r = rule(".*", "HIGH", "NEEDS_APPROVAL", null); + when(reg.getRulesForTool(eq("feishu_doc_create_c1"))).thenReturn(List.of(r)); + when(reg.getCompiledPattern(".*")).thenReturn(Pattern.compile(".*", Pattern.CASE_INSENSITIVE)); + + DbRuleGuardian g = new DbRuleGuardian(reg); + ToolInvocationContext context = ctx("feishu_doc_create_c1", "{\"title\":\"meeting notes\"}"); + assertTrue(g.supports(context)); + List findings = g.evaluate(context); + assertEquals(1, findings.size()); + GuardFinding f = findings.get(0); + assertEquals(GuardSeverity.HIGH, f.severity()); + assertEquals(GuardDecision.NEEDS_APPROVAL, f.decision()); + assertEquals("feishu_doc_create_c1", f.toolName()); + } + + @Test + @DisplayName("exclude pattern suppresses the finding") + void excludePatternSuppresses() { + ToolGuardRuleRegistry reg = mock(ToolGuardRuleRegistry.class); + ToolGuardRuleEntity r = rule("title", "HIGH", "NEEDS_APPROVAL", "test"); + when(reg.getRulesForTool(any())).thenReturn(List.of(r)); + when(reg.getCompiledPattern("title")).thenReturn(Pattern.compile("title", Pattern.CASE_INSENSITIVE)); + when(reg.getCompiledExcludePattern("test")).thenReturn(Pattern.compile("test", Pattern.CASE_INSENSITIVE)); + + DbRuleGuardian g = new DbRuleGuardian(reg); + List findings = g.evaluate(ctx("any_tool", "{\"title\":\"test notes\"}")); + assertEquals(0, findings.size(), "exclude pattern should suppress finding"); + } + + @Test + @DisplayName("non-matching pattern produces no findings") + void nonMatchingPatternNoFinding() { + ToolGuardRuleRegistry reg = mock(ToolGuardRuleRegistry.class); + ToolGuardRuleEntity r = rule("THIS_NEVER_MATCHES", "HIGH", "NEEDS_APPROVAL", null); + when(reg.getRulesForTool(any())).thenReturn(List.of(r)); + when(reg.getCompiledPattern("THIS_NEVER_MATCHES")) + .thenReturn(Pattern.compile("THIS_NEVER_MATCHES", Pattern.CASE_INSENSITIVE)); + + DbRuleGuardian g = new DbRuleGuardian(reg); + assertEquals(0, g.evaluate(ctx("any_tool", "{}")).size()); + } +}