sync: Feishu channel-native tool provider + DbRuleGuardian generic guard

This commit is contained in:
matevip 2026-05-20 12:29:29 +08:00
parent 85d7ee23c4
commit 090bb64c6a
7 changed files with 664 additions and 2 deletions

View File

@ -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:
*
* <ul>
* <li>{@code feishu_calendar_list_events} (read)
* {@code calendar/v4 calendarEvent.list}</li>
* <li>{@code feishu_doc_read} (read)
* {@code docx/v1 document.rawContent}</li>
* <li>{@code feishu_doc_create} (write)
* {@code docx/v1 document.create}</li>
* </ul>
*
* <p>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}.
*
* <p>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<ChannelToolDescriptor> describeTools() {
return FeishuToolCatalog.descriptors();
}
@Override
public List<ToolCallback> createTools(ChannelToolContext context) {
Long channelId = context.channelId();
List<ToolCallback> 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<String, Object> result = new LinkedHashMap<>();
result.put("count", items == null ? 0 : items.length);
// Compact representation title + start + end keeps the LLM context tight.
List<Map<String, Object>> events = new ArrayList<>();
if (items != null) {
for (CalendarEvent ev : items) {
Map<String, Object> 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<String, Object> 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<String, Object> 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("\"", "\\\"") + "\"}";
}
}
}

View File

@ -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).
*
* <p>Tool naming: the base name registered here is the "human"
* identifier. {@code ChannelToolService} appends {@code _c<channelId>}
* before registering with {@code ToolRegistry}, so the actual name an
* Agent sees is e.g. {@code feishu_doc_create_c2055137662148763649}.
*
* <p>Initial set covers the most useful read + a representative write
* per resource family the rest land as follow-ups:
* <ul>
* <li><b>feishu_calendar_list_events</b> read; default-on</li>
* <li><b>feishu_doc_read</b> read; default-on</li>
* <li><b>feishu_doc_create</b> write; default-off, approval-gated</li>
* </ul>
*/
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<ChannelToolDescriptor> 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\"]}";
}
}

View File

@ -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<String, ChannelToolProvider> 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<String, String> 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.
*
* <p>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<ChannelToolDescriptor> descriptors, Map<String, String> 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<ToolGuardRuleEntity>().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<ToolEntity>().eq(ToolEntity::getChannelId, channelId));

View File

@ -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.
*
* <p><b>Disjointness contract</b>: {@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<GuardFinding> evaluate(ToolInvocationContext context) {
String combined = buildMatchInput(context);
if (combined == null || combined.isEmpty()) return List.of();
List<GuardFinding> 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);
}
}

View File

@ -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

View File

@ -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<ChannelToolDescriptor> ds = FeishuToolCatalog.descriptors();
Set<String> 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<String, ChannelToolDescriptor> 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());
}
}
}

View File

@ -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:
* <ul>
* <li>supports(): true iff DB rules exist for the tool</li>
* <li>evaluate(): produces a finding when the rule pattern matches</li>
* <li>exclude pattern suppresses the finding</li>
* <li>rule decision carries through to GuardFinding.decision</li>
* </ul>
*/
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<GuardFinding> 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<GuardFinding> 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());
}
}