mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
sync: Feishu channel-native tool provider + DbRuleGuardian generic guard
This commit is contained in:
parent
85d7ee23c4
commit
090bb64c6a
@ -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("\"", "\\\"") + "\"}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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\"]}";
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user