mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
sync: Feishu CardKit streaming-card adapter via cardkit/v1 SDK
This commit is contained in:
parent
0e1b8ca564
commit
35f010d7a1
@ -92,6 +92,14 @@ public class ChannelManager {
|
|||||||
*/
|
*/
|
||||||
private final vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber;
|
private final vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feishu CardKit streaming-card manager. Drives
|
||||||
|
* {@link vip.mate.channel.feishu.FeishuChannelAdapter}'s
|
||||||
|
* {@code processStream} so the receiver sees text appearing
|
||||||
|
* character-by-character instead of waiting for the full reply.
|
||||||
|
*/
|
||||||
|
private final vip.mate.channel.feishu.FeishuStreamingCardManager feishuStreamingCardManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distributed leader election. Channels whose adapter reports
|
* Distributed leader election. Channels whose adapter reports
|
||||||
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
|
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
|
||||||
@ -1158,7 +1166,7 @@ public class ChannelManager {
|
|||||||
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
|
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
|
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
|
||||||
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
|
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
|
||||||
feishuMediaUploader, generatedFileScrubber);
|
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager);
|
||||||
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
|
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
|
||||||
|
|||||||
@ -5,10 +5,13 @@ import com.lark.oapi.event.EventDispatcher;
|
|||||||
import com.lark.oapi.service.im.ImService;
|
import com.lark.oapi.service.im.ImService;
|
||||||
import com.lark.oapi.service.im.v1.model.P2MessageReceiveV1;
|
import com.lark.oapi.service.im.v1.model.P2MessageReceiveV1;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import vip.mate.agent.AgentService.StreamDelta;
|
||||||
import vip.mate.channel.AbstractChannelAdapter;
|
import vip.mate.channel.AbstractChannelAdapter;
|
||||||
import vip.mate.channel.ChannelMessage;
|
import vip.mate.channel.ChannelMessage;
|
||||||
import vip.mate.channel.ChannelMessageRouter;
|
import vip.mate.channel.ChannelMessageRouter;
|
||||||
import vip.mate.channel.ExponentialBackoff;
|
import vip.mate.channel.ExponentialBackoff;
|
||||||
|
import vip.mate.channel.StreamingChannelAdapter;
|
||||||
import vip.mate.channel.media.GeneratedFileScrubber;
|
import vip.mate.channel.media.GeneratedFileScrubber;
|
||||||
import vip.mate.channel.media.MediaSource;
|
import vip.mate.channel.media.MediaSource;
|
||||||
import vip.mate.channel.media.MediaUploadException;
|
import vip.mate.channel.media.MediaUploadException;
|
||||||
@ -66,7 +69,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
public class FeishuChannelAdapter extends AbstractChannelAdapter implements StreamingChannelAdapter {
|
||||||
|
|
||||||
public static final String CHANNEL_TYPE = "feishu";
|
public static final String CHANNEL_TYPE = "feishu";
|
||||||
|
|
||||||
@ -128,10 +131,13 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
/** Scrubs {@code /api/v1/files/generated/{id}} URLs into native attachments. Nullable for legacy callers. */
|
/** Scrubs {@code /api/v1/files/generated/{id}} URLs into native attachments. Nullable for legacy callers. */
|
||||||
private final GeneratedFileScrubber generatedFileScrubber;
|
private final GeneratedFileScrubber generatedFileScrubber;
|
||||||
|
|
||||||
|
/** CardKit streaming-card manager. Nullable for legacy callers / tests. */
|
||||||
|
private final FeishuStreamingCardManager streamingCardManager;
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this(channelEntity, messageRouter, objectMapper, null, null);
|
this(channelEntity, messageRouter, objectMapper, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
@ -139,9 +145,19 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
FeishuMediaUploader mediaUploader,
|
FeishuMediaUploader mediaUploader,
|
||||||
GeneratedFileScrubber generatedFileScrubber) {
|
GeneratedFileScrubber generatedFileScrubber) {
|
||||||
|
this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||||
|
ChannelMessageRouter messageRouter,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
FeishuMediaUploader mediaUploader,
|
||||||
|
GeneratedFileScrubber generatedFileScrubber,
|
||||||
|
FeishuStreamingCardManager streamingCardManager) {
|
||||||
super(channelEntity, messageRouter, objectMapper);
|
super(channelEntity, messageRouter, objectMapper);
|
||||||
this.mediaUploader = mediaUploader;
|
this.mediaUploader = mediaUploader;
|
||||||
this.generatedFileScrubber = generatedFileScrubber;
|
this.generatedFileScrubber = generatedFileScrubber;
|
||||||
|
this.streamingCardManager = streamingCardManager;
|
||||||
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
|
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
|
||||||
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
|
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
|
||||||
}
|
}
|
||||||
@ -1437,6 +1453,136 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
|||||||
*/
|
*/
|
||||||
static final int MAX_TEXT_MESSAGE_CHARS = 4000;
|
static final int MAX_TEXT_MESSAGE_CHARS = 4000;
|
||||||
|
|
||||||
|
// ==================== StreamingChannelAdapter ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream consumption strategy:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code card_streaming_enabled=true} (default) + manager wired →
|
||||||
|
* create a {@code cardkit/v1} streaming card, throttle-update its
|
||||||
|
* markdown element on every delta, finalise on completion. The
|
||||||
|
* receiver sees text appearing character-by-character like a
|
||||||
|
* chat-app's typing animation.</li>
|
||||||
|
* <li>Manager missing OR card creation fails OR config opts out →
|
||||||
|
* fall back to {@link #processStreamAsText}: accumulate every
|
||||||
|
* chunk and send one final regular message.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String processStream(Flux<StreamDelta> stream, ChannelMessage message, String conversationId) {
|
||||||
|
if (!isCardStreamingEnabled() || streamingCardManager == null
|
||||||
|
|| channelEntity == null) {
|
||||||
|
return processStreamAsText(stream, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
String receiveId = pickReceiveId(message);
|
||||||
|
if (receiveId == null) {
|
||||||
|
log.warn("[feishu-stream] No usable receive id on message; falling back to text mode");
|
||||||
|
return processStreamAsText(stream, message);
|
||||||
|
}
|
||||||
|
String receiveIdType = resolveReceiveIdType(receiveId);
|
||||||
|
|
||||||
|
String sessionKey = streamingCardManager.createAndDeliver(
|
||||||
|
channelEntity.getId(), receiveIdType, receiveId, null);
|
||||||
|
if (sessionKey == null) {
|
||||||
|
log.info("[feishu-stream] Card create/deliver failed; falling back to text mode");
|
||||||
|
return processStreamAsText(stream, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder accumulator = new StringBuilder();
|
||||||
|
try {
|
||||||
|
stream.doOnNext(delta -> {
|
||||||
|
if (delta.content() != null) {
|
||||||
|
accumulator.append(delta.content());
|
||||||
|
streamingCardManager.appendContent(sessionKey, delta.content(), false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.doOnError(err -> {
|
||||||
|
log.error("[feishu-stream] stream error: sessionKey={}, err={}",
|
||||||
|
sessionKey, err.getMessage());
|
||||||
|
streamingCardManager.failCard(sessionKey, err.getMessage());
|
||||||
|
})
|
||||||
|
.blockLast(Duration.ofMinutes(5));
|
||||||
|
|
||||||
|
String finalContent = accumulator.toString();
|
||||||
|
if (finalContent.isBlank()) {
|
||||||
|
finalContent = "(无回复内容)";
|
||||||
|
}
|
||||||
|
streamingCardManager.finishCard(sessionKey, finalContent);
|
||||||
|
log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}",
|
||||||
|
sessionKey, finalContent.length());
|
||||||
|
return finalContent;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[feishu-stream] Card streaming failed: sessionKey={}, err={}",
|
||||||
|
sessionKey, e.getMessage(), e);
|
||||||
|
streamingCardManager.failCard(sessionKey, e.getMessage());
|
||||||
|
|
||||||
|
// Tag returned content with the "[错误] " prefix so
|
||||||
|
// ChannelMessageRouter.isErrorReply flips status='error' on the
|
||||||
|
// persisted row and BaseAgent.sanitizeForLlm filters it out of
|
||||||
|
// the next turn's history. Without this, partial streaming
|
||||||
|
// output (e.g. LLM 400'd mid-stream) would re-enter the prompt
|
||||||
|
// as a valid assistant turn and re-trigger the same 400.
|
||||||
|
String partial = accumulator.toString();
|
||||||
|
String errorPrefix = "[错误] Feishu CardKit streaming failed: " + e.getMessage();
|
||||||
|
if (!partial.isBlank()) {
|
||||||
|
return errorPrefix + "\n\n(已生成的部分内容,已忽略)\n" + partial;
|
||||||
|
}
|
||||||
|
throw new RuntimeException(errorPrefix, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming fallback — accumulate all deltas, then send through the
|
||||||
|
* existing {@link #sendMessage} path so the message goes out as a
|
||||||
|
* regular text bubble (auto-upgraded to a non-streaming card by the
|
||||||
|
* existing {@code card_format} logic when content looks card-worthy).
|
||||||
|
*/
|
||||||
|
private String processStreamAsText(Flux<StreamDelta> stream, ChannelMessage message) {
|
||||||
|
StringBuilder accumulator = new StringBuilder();
|
||||||
|
stream.doOnNext(delta -> {
|
||||||
|
if (delta.content() != null) {
|
||||||
|
accumulator.append(delta.content());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.blockLast(Duration.ofMinutes(5));
|
||||||
|
String finalContent = accumulator.toString();
|
||||||
|
if (!finalContent.isBlank()) {
|
||||||
|
String replyTarget = message.getReplyToken() != null
|
||||||
|
? message.getReplyToken()
|
||||||
|
: (message.getChatId() != null ? message.getChatId() : message.getSenderId());
|
||||||
|
if (replyTarget != null) {
|
||||||
|
sendMessage(replyTarget, finalContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finalContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the best id to receive a streaming card — prefer reply token, then chat, then sender. */
|
||||||
|
private static String pickReceiveId(ChannelMessage message) {
|
||||||
|
if (message == null) return null;
|
||||||
|
if (message.getReplyToken() != null && !message.getReplyToken().isBlank()) {
|
||||||
|
return message.getReplyToken();
|
||||||
|
}
|
||||||
|
if (message.getChatId() != null && !message.getChatId().isBlank()) {
|
||||||
|
return message.getChatId();
|
||||||
|
}
|
||||||
|
if (message.getSenderId() != null && !message.getSenderId().isBlank()) {
|
||||||
|
return message.getSenderId();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isCardStreamingEnabled() {
|
||||||
|
// Default true — streaming cards are the better UX when CardKit is
|
||||||
|
// available. Operators can flip card_streaming_enabled=false in
|
||||||
|
// configJson to fall back to the text path (useful for debugging
|
||||||
|
// or when targeting an old Feishu tenant that hasn't rolled out
|
||||||
|
// CardKit v1 universally).
|
||||||
|
return getConfigBoolean("card_streaming_enabled", true);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void sendMessage(String targetId, String content) {
|
public void sendMessage(String targetId, String content) {
|
||||||
if (httpClient == null) {
|
if (httpClient == null) {
|
||||||
|
|||||||
@ -0,0 +1,440 @@
|
|||||||
|
package vip.mate.channel.feishu;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.lark.oapi.Client;
|
||||||
|
import com.lark.oapi.service.cardkit.v1.model.ContentCardElementReq;
|
||||||
|
import com.lark.oapi.service.cardkit.v1.model.ContentCardElementReqBody;
|
||||||
|
import com.lark.oapi.service.cardkit.v1.model.ContentCardElementResp;
|
||||||
|
import com.lark.oapi.service.cardkit.v1.model.CreateCardReq;
|
||||||
|
import com.lark.oapi.service.cardkit.v1.model.CreateCardReqBody;
|
||||||
|
import com.lark.oapi.service.cardkit.v1.model.CreateCardResp;
|
||||||
|
import com.lark.oapi.service.cardkit.v1.model.SettingsCardReq;
|
||||||
|
import com.lark.oapi.service.cardkit.v1.model.SettingsCardReqBody;
|
||||||
|
import com.lark.oapi.service.cardkit.v1.model.SettingsCardResp;
|
||||||
|
import com.lark.oapi.service.im.v1.model.CreateMessageReq;
|
||||||
|
import com.lark.oapi.service.im.v1.model.CreateMessageReqBody;
|
||||||
|
import com.lark.oapi.service.im.v1.model.CreateMessageResp;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming-card lifecycle for the Feishu CardKit v1 API.
|
||||||
|
*
|
||||||
|
* <p>Flow per stream:
|
||||||
|
* <ol>
|
||||||
|
* <li>{@link #createAndDeliver} — build a {@code streaming_mode=true}
|
||||||
|
* schema-2.0 card via {@code cardkit/v1/card.create}, then send
|
||||||
|
* it to the user via {@code im/v1/message.create} as an
|
||||||
|
* {@code interactive} message referencing the new {@code card_id}.
|
||||||
|
* Returns a session key used by subsequent calls.</li>
|
||||||
|
* <li>{@link #appendContent} — accumulate delta text and, when the
|
||||||
|
* throttle window permits or the caller forces a flush, push the
|
||||||
|
* current accumulator to the card's markdown element via
|
||||||
|
* {@code cardkit/v1/cardElement.content} with a monotonic
|
||||||
|
* sequence number.</li>
|
||||||
|
* <li>{@link #finishCard} — push the final full content one last
|
||||||
|
* time, then turn off {@code streaming_mode} via
|
||||||
|
* {@code cardkit/v1/card.settings} so the receiver UI stops
|
||||||
|
* showing the typing animation.</li>
|
||||||
|
* <li>{@link #failCard} — append an error marker to whatever was
|
||||||
|
* accumulated, then close streaming the same way.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Designed mirror-image to {@code DingTalkAICardManager}: same
|
||||||
|
* create/append/finish/fail shape, same per-session throttling,
|
||||||
|
* same activeSessions map for hand-off between threads. The
|
||||||
|
* implementation is end-to-end {@code oapi-sdk} — no hand-rolled HTTP.
|
||||||
|
*
|
||||||
|
* <p>The four SDK call sites are {@code protected} so unit tests can
|
||||||
|
* subclass and verify session/throttle behavior without booting a real
|
||||||
|
* Feishu credential or hitting the network.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class FeishuStreamingCardManager {
|
||||||
|
|
||||||
|
/** Throttle window for {@link #appendContent}, ms — matches DingTalk AICard. */
|
||||||
|
static final long THROTTLE_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown element id baked into the initial streaming card.
|
||||||
|
* Content-update calls reference this id. Public so tests can assert.
|
||||||
|
*/
|
||||||
|
public static final String STREAM_ELEMENT_ID = "stream_md";
|
||||||
|
|
||||||
|
/** Default text shown when the card is first created, before any delta arrives. */
|
||||||
|
public static final String DEFAULT_INITIAL_TEXT = "🤔 思考中...";
|
||||||
|
|
||||||
|
private final FeishuClientFactory clientFactory;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/** sessionKey → CardSession. sessionKey is an opaque UUID handed back to the caller. */
|
||||||
|
private final ConcurrentHashMap<String, CardSession> activeSessions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public FeishuStreamingCardManager(FeishuClientFactory clientFactory, ObjectMapper objectMapper) {
|
||||||
|
this.clientFactory = clientFactory;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Session state
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Terminal-state CAS guard — at most one of {finishCard, failCard} wins per session. */
|
||||||
|
enum Status { STREAMING, FINISHED, FAILED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One in-flight streaming card. State is mutated by a single Reactor
|
||||||
|
* thread per session (the one consuming the {@code Flux}), so all
|
||||||
|
* mutable fields are either {@code volatile} (visibility across the
|
||||||
|
* eventual terminal call) or guarded by the session monitor.
|
||||||
|
*/
|
||||||
|
static final class CardSession {
|
||||||
|
final String sessionKey;
|
||||||
|
final Long channelId;
|
||||||
|
final String cardId;
|
||||||
|
final String messageId;
|
||||||
|
final StringBuilder accumulated = new StringBuilder();
|
||||||
|
final AtomicInteger sequence = new AtomicInteger(0);
|
||||||
|
final AtomicReference<Status> status = new AtomicReference<>(Status.STREAMING);
|
||||||
|
/**
|
||||||
|
* Time of the most recent flush. Initialised to a value well in
|
||||||
|
* the past so the very first {@link #appendContent} always
|
||||||
|
* flushes — the receiver sees the first token instantly instead
|
||||||
|
* of waiting up to {@link #THROTTLE_INTERVAL_MS} for the second.
|
||||||
|
*/
|
||||||
|
volatile long lastFlushMs = Long.MIN_VALUE / 2;
|
||||||
|
|
||||||
|
CardSession(String sessionKey, Long channelId, String cardId, String messageId) {
|
||||||
|
this.sessionKey = sessionKey;
|
||||||
|
this.channelId = channelId;
|
||||||
|
this.cardId = cardId;
|
||||||
|
this.messageId = messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isStreaming() {
|
||||||
|
return status.get() == Status.STREAMING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the streaming card, push it as an interactive message, and
|
||||||
|
* register an in-memory session.
|
||||||
|
*
|
||||||
|
* @param channelId mate_channel row id — picks SDK client
|
||||||
|
* @param receiveIdType one of {@code open_id} / {@code chat_id} /
|
||||||
|
* {@code email} / {@code union_id} /
|
||||||
|
* {@code user_id}
|
||||||
|
* @param receiveId the chat or user id to receive the card
|
||||||
|
* @param initialText bubble text shown before the first delta;
|
||||||
|
* null → {@link #DEFAULT_INITIAL_TEXT}
|
||||||
|
* @return sessionKey for subsequent calls, or null on failure
|
||||||
|
*/
|
||||||
|
public String createAndDeliver(Long channelId, String receiveIdType,
|
||||||
|
String receiveId, String initialText) {
|
||||||
|
if (channelId == null || receiveIdType == null || receiveId == null) {
|
||||||
|
log.warn("[feishu-stream] createAndDeliver missing required arg(s)");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String firstText = (initialText == null || initialText.isBlank())
|
||||||
|
? DEFAULT_INITIAL_TEXT
|
||||||
|
: initialText;
|
||||||
|
try {
|
||||||
|
Client client = clientFactory.client(channelId);
|
||||||
|
|
||||||
|
String cardId = sdkCreateCard(client, firstText);
|
||||||
|
if (cardId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String messageId = sdkSendInteractiveMessage(client, receiveIdType, receiveId, cardId);
|
||||||
|
if (messageId == null) {
|
||||||
|
// Card built but couldn't deliver — best effort close so the
|
||||||
|
// server-side card isn't orphaned in streaming mode forever.
|
||||||
|
tryCloseStreamingSilently(client, cardId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String sessionKey = UUID.randomUUID().toString();
|
||||||
|
CardSession session = new CardSession(sessionKey, channelId, cardId, messageId);
|
||||||
|
activeSessions.put(sessionKey, session);
|
||||||
|
log.info("[feishu-stream] Card created: sessionKey={}, cardId={}, messageId={}",
|
||||||
|
sessionKey, abbrev(cardId), abbrev(messageId));
|
||||||
|
return sessionKey;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[feishu-stream] createAndDeliver failed: {}", e.getMessage(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append delta text to the running session. May flush immediately
|
||||||
|
* (force) or wait for the next throttle window.
|
||||||
|
*
|
||||||
|
* <p>No-op when {@code sessionKey} is unknown or the session has
|
||||||
|
* already reached a terminal status — keeps the caller's
|
||||||
|
* {@code doOnNext} loop simple ("just push every chunk").
|
||||||
|
*/
|
||||||
|
public void appendContent(String sessionKey, String contentDelta, boolean forceFlush) {
|
||||||
|
CardSession session = activeSessions.get(sessionKey);
|
||||||
|
if (session == null || !session.isStreaming()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentDelta != null && !contentDelta.isEmpty()) {
|
||||||
|
synchronized (session) {
|
||||||
|
session.accumulated.append(contentDelta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long now = currentTimeMs();
|
||||||
|
if (!forceFlush && now - session.lastFlushMs < THROTTLE_INTERVAL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flush(session, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push final content and turn off streaming mode. Idempotent —
|
||||||
|
* a second call is a no-op. After return, the sessionKey is no
|
||||||
|
* longer known to the manager.
|
||||||
|
*/
|
||||||
|
public void finishCard(String sessionKey, String finalContent) {
|
||||||
|
CardSession session = activeSessions.get(sessionKey);
|
||||||
|
if (session == null) return;
|
||||||
|
if (!session.status.compareAndSet(Status.STREAMING, Status.FINISHED)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
replaceAccumulated(session, finalContent != null ? finalContent : "");
|
||||||
|
flush(session, currentTimeMs());
|
||||||
|
closeStreaming(session);
|
||||||
|
} finally {
|
||||||
|
activeSessions.remove(sessionKey);
|
||||||
|
log.info("[feishu-stream] Card finished: sessionKey={}, contentLen={}",
|
||||||
|
sessionKey, finalContent == null ? 0 : finalContent.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the session failed. The current accumulator gets an error
|
||||||
|
* suffix; the card is closed so the typing animation stops.
|
||||||
|
* Idempotent.
|
||||||
|
*/
|
||||||
|
public void failCard(String sessionKey, String errorMessage) {
|
||||||
|
CardSession session = activeSessions.get(sessionKey);
|
||||||
|
if (session == null) return;
|
||||||
|
if (!session.status.compareAndSet(Status.STREAMING, Status.FAILED)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String tail;
|
||||||
|
synchronized (session) {
|
||||||
|
if (session.accumulated.length() == 0) {
|
||||||
|
tail = "⚠️ 处理失败:" + safe(errorMessage);
|
||||||
|
} else {
|
||||||
|
tail = session.accumulated + "\n\n⚠️ " + safe(errorMessage);
|
||||||
|
}
|
||||||
|
session.accumulated.setLength(0);
|
||||||
|
session.accumulated.append(tail);
|
||||||
|
}
|
||||||
|
flush(session, currentTimeMs());
|
||||||
|
closeStreaming(session);
|
||||||
|
} finally {
|
||||||
|
activeSessions.remove(sessionKey);
|
||||||
|
log.warn("[feishu-stream] Card failed: sessionKey={}, error={}", sessionKey, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Inspection helpers (tests / metrics)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Visible for tests / metrics — number of in-flight sessions. */
|
||||||
|
public int activeSessionCount() {
|
||||||
|
return activeSessions.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests — direct session lookup. */
|
||||||
|
CardSession sessionFor(String sessionKey) {
|
||||||
|
return activeSessions.get(sessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Internal — flush + SDK seams
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void flush(CardSession session, long now) {
|
||||||
|
String snapshot;
|
||||||
|
synchronized (session) {
|
||||||
|
snapshot = session.accumulated.toString();
|
||||||
|
}
|
||||||
|
int seq = session.sequence.incrementAndGet();
|
||||||
|
try {
|
||||||
|
Client client = clientFactory.client(session.channelId);
|
||||||
|
sdkPushElementContent(client, session.cardId, STREAM_ELEMENT_ID, snapshot, seq);
|
||||||
|
session.lastFlushMs = now;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[feishu-stream] flush failed: sessionKey={}, seq={}, err={}",
|
||||||
|
session.sessionKey, seq, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeStreaming(CardSession session) {
|
||||||
|
int seq = session.sequence.incrementAndGet();
|
||||||
|
try {
|
||||||
|
Client client = clientFactory.client(session.channelId);
|
||||||
|
sdkCloseStreamingMode(client, session.cardId, seq);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[feishu-stream] closeStreaming failed: sessionKey={}, err={}",
|
||||||
|
session.sessionKey, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void tryCloseStreamingSilently(Client client, String cardId) {
|
||||||
|
try {
|
||||||
|
sdkCloseStreamingMode(client, cardId, 1);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// best-effort — already in an error path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void replaceAccumulated(CardSession session, String content) {
|
||||||
|
synchronized (session) {
|
||||||
|
session.accumulated.setLength(0);
|
||||||
|
session.accumulated.append(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// SDK seams (overridable in tests)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Build a streaming-mode schema-2.0 card. Returns the new card_id or null. */
|
||||||
|
protected String sdkCreateCard(Client client, String initialText) throws Exception {
|
||||||
|
String cardJson = objectMapper.writeValueAsString(buildInitialCardJson(initialText));
|
||||||
|
CreateCardReq req = CreateCardReq.newBuilder()
|
||||||
|
.createCardReqBody(CreateCardReqBody.newBuilder()
|
||||||
|
.type("card_json")
|
||||||
|
.data(cardJson)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
CreateCardResp resp = client.cardkit().v1().card().create(req);
|
||||||
|
if (!resp.success() || resp.getData() == null) {
|
||||||
|
log.warn("[feishu-stream] card.create failed: code={}, msg={}", resp.getCode(), resp.getMsg());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resp.getData().getCardId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send the freshly-built card as an interactive message. Returns message_id or null. */
|
||||||
|
protected String sdkSendInteractiveMessage(Client client, String receiveIdType,
|
||||||
|
String receiveId, String cardId) throws Exception {
|
||||||
|
Map<String, Object> content = Map.of(
|
||||||
|
"type", "card",
|
||||||
|
"data", Map.of("card_id", cardId)
|
||||||
|
);
|
||||||
|
CreateMessageReq req = CreateMessageReq.newBuilder()
|
||||||
|
.receiveIdType(receiveIdType)
|
||||||
|
.createMessageReqBody(CreateMessageReqBody.newBuilder()
|
||||||
|
.receiveId(receiveId)
|
||||||
|
.msgType("interactive")
|
||||||
|
.content(objectMapper.writeValueAsString(content))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
CreateMessageResp resp = client.im().v1().message().create(req);
|
||||||
|
if (!resp.success() || resp.getData() == null) {
|
||||||
|
log.warn("[feishu-stream] interactive message send failed: code={}, msg={}",
|
||||||
|
resp.getCode(), resp.getMsg());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resp.getData().getMessageId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push the latest accumulator snapshot to the streaming element. */
|
||||||
|
protected void sdkPushElementContent(Client client, String cardId, String elementId,
|
||||||
|
String content, int sequence) throws Exception {
|
||||||
|
ContentCardElementReq req = ContentCardElementReq.newBuilder()
|
||||||
|
.cardId(cardId)
|
||||||
|
.elementId(elementId)
|
||||||
|
.contentCardElementReqBody(ContentCardElementReqBody.newBuilder()
|
||||||
|
.content(content)
|
||||||
|
.uuid(UUID.randomUUID().toString())
|
||||||
|
.sequence(sequence)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
ContentCardElementResp resp = client.cardkit().v1().cardElement().content(req);
|
||||||
|
if (!resp.success()) {
|
||||||
|
log.warn("[feishu-stream] cardElement.content failed: cardId={}, seq={}, code={}, msg={}",
|
||||||
|
abbrev(cardId), sequence, resp.getCode(), resp.getMsg());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flip streaming_mode=false so the receiving UI stops the typing animation. */
|
||||||
|
protected void sdkCloseStreamingMode(Client client, String cardId, int sequence) throws Exception {
|
||||||
|
Map<String, Object> settings = Map.of(
|
||||||
|
"config", Map.of("streaming_mode", false)
|
||||||
|
);
|
||||||
|
SettingsCardReq req = SettingsCardReq.newBuilder()
|
||||||
|
.cardId(cardId)
|
||||||
|
.settingsCardReqBody(SettingsCardReqBody.newBuilder()
|
||||||
|
.settings(objectMapper.writeValueAsString(settings))
|
||||||
|
.uuid(UUID.randomUUID().toString())
|
||||||
|
.sequence(sequence)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
SettingsCardResp resp = client.cardkit().v1().card().settings(req);
|
||||||
|
if (!resp.success()) {
|
||||||
|
log.warn("[feishu-stream] card.settings (close) failed: cardId={}, code={}, msg={}",
|
||||||
|
abbrev(cardId), resp.getCode(), resp.getMsg());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Test seams + tiny helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Overridable so tests can pin time without involving Clock + reflection. */
|
||||||
|
protected long currentTimeMs() {
|
||||||
|
return System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible for tests. The "schema 2.0 streaming card" baseline. */
|
||||||
|
Map<String, Object> buildInitialCardJson(String initialText) {
|
||||||
|
// LinkedHashMap → deterministic JSON order, easier to log-grep
|
||||||
|
Map<String, Object> config = new LinkedHashMap<>();
|
||||||
|
config.put("streaming_mode", true);
|
||||||
|
config.put("update_multi", true);
|
||||||
|
|
||||||
|
Map<String, Object> element = new LinkedHashMap<>();
|
||||||
|
element.put("tag", "markdown");
|
||||||
|
element.put("element_id", STREAM_ELEMENT_ID);
|
||||||
|
element.put("content", initialText);
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("elements", List.of(element));
|
||||||
|
|
||||||
|
Map<String, Object> card = new LinkedHashMap<>();
|
||||||
|
card.put("schema", "2.0");
|
||||||
|
card.put("config", config);
|
||||||
|
card.put("body", body);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String abbrev(String s) {
|
||||||
|
if (s == null || s.length() <= 12) return s == null ? "" : s;
|
||||||
|
return s.substring(0, 12) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String s) {
|
||||||
|
return s == null ? "" : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ import vip.mate.tool.guard.service.ToolGuardConfigService;
|
|||||||
import vip.mate.tool.guard.service.ToolGuardRuleService;
|
import vip.mate.tool.guard.service.ToolGuardRuleService;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||||
|
|
||||||
@ -166,6 +167,36 @@ public class SecurityController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "导出全部规则为 JSON")
|
||||||
|
@GetMapping("/guard/rules/export")
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
public R<Map<String, Object>> exportRules() {
|
||||||
|
return R.ok(ruleService.exportRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "从 JSON 批量导入规则(upsert 语义)")
|
||||||
|
@PostMapping("/guard/rules/import")
|
||||||
|
@RequireWorkspaceRole("admin")
|
||||||
|
public R<Map<String, Object>> importRules(@RequestBody Map<String, Object> body) {
|
||||||
|
try {
|
||||||
|
Object rulesNode = body == null ? null : body.get("rules");
|
||||||
|
if (!(rulesNode instanceof List<?> raw)) {
|
||||||
|
return R.fail("Body must contain a 'rules' array");
|
||||||
|
}
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
List<ToolGuardRuleEntity> incoming = new java.util.ArrayList<>();
|
||||||
|
for (Object item : raw) {
|
||||||
|
ToolGuardRuleEntity rule = om.convertValue(item, ToolGuardRuleEntity.class);
|
||||||
|
incoming.add(rule);
|
||||||
|
}
|
||||||
|
return R.ok(ruleService.importRules(incoming));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return R.fail(e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return R.fail("Import failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Audit ====================
|
// ==================== Audit ====================
|
||||||
|
|
||||||
@Operation(summary = "审计日志")
|
@Operation(summary = "审计日志")
|
||||||
|
|||||||
@ -66,6 +66,19 @@ public class ToolGuardRuleRegistry implements ApplicationRunner {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 category 取所有已启用规则(不限工具)。
|
||||||
|
* 用于 alwaysRun 类的横切 Guardian(凭据扫描、PII 扫描等)。
|
||||||
|
*/
|
||||||
|
public List<ToolGuardRuleEntity> getRulesByCategory(String category) {
|
||||||
|
if (category == null || category.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return allRules.stream()
|
||||||
|
.filter(r -> category.equals(r.getCategory()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有已启用规则
|
* 获取所有已启用规则
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import org.springframework.stereotype.Component;
|
|||||||
import vip.mate.tool.guard.model.*;
|
import vip.mate.tool.guard.model.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 策略解析器
|
* 策略解析器
|
||||||
@ -15,44 +14,66 @@ import java.util.Set;
|
|||||||
* <li>Guardian 只负责发现风险事实</li>
|
* <li>Guardian 只负责发现风险事实</li>
|
||||||
* <li>PolicyResolver 负责把事实映射为执行策略</li>
|
* <li>PolicyResolver 负责把事实映射为执行策略</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* 采用 findings-driven approval 策略,不按工具类型默认审批。
|
* 聚合规则(取最严格者):
|
||||||
|
* <ol>
|
||||||
|
* <li>遍历每条 finding,得到该 finding 的目标 decision:</li>
|
||||||
|
* <ul>
|
||||||
|
* <li>若 finding 显式带 decision(来自 DB 规则),用之;</li>
|
||||||
|
* <li>否则按 severity 回退:CRITICAL→BLOCK,HIGH/MEDIUM→NEEDS_APPROVAL,LOW/INFO→ALLOW。</li>
|
||||||
|
* </ul>
|
||||||
|
* <li>取所有 finding 中最严格的:BLOCK > NEEDS_APPROVAL > ALLOW。</li>
|
||||||
|
* </ol>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class ToolPolicyResolver {
|
public class ToolPolicyResolver {
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 findings 和上下文产出最终裁决
|
|
||||||
* <p>
|
|
||||||
* 策略(findings-driven approval):
|
|
||||||
* <ul>
|
|
||||||
* <li>无 findings → ALLOW(普通命令直接执行)</li>
|
|
||||||
* <li>CRITICAL → BLOCK(极端危险直接阻断)</li>
|
|
||||||
* <li>HIGH → NEEDS_APPROVAL(高风险需审批)</li>
|
|
||||||
* <li>MEDIUM → NEEDS_APPROVAL(中风险需审批)</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
public GuardDecision resolve(List<GuardFinding> findings, ToolInvocationContext context) {
|
public GuardDecision resolve(List<GuardFinding> findings, ToolInvocationContext context) {
|
||||||
// 无 findings → 直接允许(不再按工具类型默认审批)
|
|
||||||
if (findings == null || findings.isEmpty()) {
|
if (findings == null || findings.isEmpty()) {
|
||||||
return GuardDecision.ALLOW;
|
return GuardDecision.ALLOW;
|
||||||
}
|
}
|
||||||
|
|
||||||
GuardSeverity maxSeverity = findings.stream()
|
GuardDecision aggregate = GuardDecision.ALLOW;
|
||||||
.map(GuardFinding::severity)
|
for (GuardFinding f : findings) {
|
||||||
.reduce(GuardSeverity.INFO, GuardSeverity::max);
|
GuardDecision perFinding = resolveSingle(f);
|
||||||
|
aggregate = stricter(aggregate, perFinding);
|
||||||
|
if (aggregate == GuardDecision.BLOCK) {
|
||||||
|
return aggregate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aggregate;
|
||||||
|
}
|
||||||
|
|
||||||
// CRITICAL → 直接 BLOCK
|
/**
|
||||||
if (maxSeverity.isAtLeast(GuardSeverity.CRITICAL)) {
|
* 单条 finding 的目标 decision:显式优先,否则按 severity 默认映射
|
||||||
|
*/
|
||||||
|
private GuardDecision resolveSingle(GuardFinding finding) {
|
||||||
|
if (finding.decision() != null) {
|
||||||
|
return finding.decision();
|
||||||
|
}
|
||||||
|
GuardSeverity sev = finding.severity();
|
||||||
|
if (sev == null) {
|
||||||
|
return GuardDecision.ALLOW;
|
||||||
|
}
|
||||||
|
if (sev.isAtLeast(GuardSeverity.CRITICAL)) {
|
||||||
return GuardDecision.BLOCK;
|
return GuardDecision.BLOCK;
|
||||||
}
|
}
|
||||||
|
if (sev.isAtLeast(GuardSeverity.MEDIUM)) {
|
||||||
// HIGH / MEDIUM → 需要审批
|
|
||||||
if (maxSeverity.isAtLeast(GuardSeverity.MEDIUM)) {
|
|
||||||
return GuardDecision.NEEDS_APPROVAL;
|
return GuardDecision.NEEDS_APPROVAL;
|
||||||
}
|
}
|
||||||
|
return GuardDecision.ALLOW;
|
||||||
|
}
|
||||||
|
|
||||||
// LOW / INFO → 允许
|
/**
|
||||||
|
* 取两个 decision 的严格上界:BLOCK > NEEDS_APPROVAL > ALLOW
|
||||||
|
*/
|
||||||
|
private GuardDecision stricter(GuardDecision a, GuardDecision b) {
|
||||||
|
if (a == GuardDecision.BLOCK || b == GuardDecision.BLOCK) {
|
||||||
|
return GuardDecision.BLOCK;
|
||||||
|
}
|
||||||
|
if (a == GuardDecision.NEEDS_APPROVAL || b == GuardDecision.NEEDS_APPROVAL) {
|
||||||
|
return GuardDecision.NEEDS_APPROVAL;
|
||||||
|
}
|
||||||
return GuardDecision.ALLOW;
|
return GuardDecision.ALLOW;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,14 +82,12 @@ public class ToolPolicyResolver {
|
|||||||
*/
|
*/
|
||||||
public String buildSummary(List<GuardFinding> findings, GuardDecision decision) {
|
public String buildSummary(List<GuardFinding> findings, GuardDecision decision) {
|
||||||
if (findings == null || findings.isEmpty()) {
|
if (findings == null || findings.isEmpty()) {
|
||||||
// 无 findings 时不应该有 NEEDS_APPROVAL 或 BLOCK
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append("检测到 ").append(findings.size()).append(" 项安全风险");
|
sb.append("检测到 ").append(findings.size()).append(" 项安全风险");
|
||||||
|
|
||||||
// 列出最高风险的发现
|
|
||||||
findings.stream()
|
findings.stream()
|
||||||
.filter(f -> f.severity() != null && f.severity().isAtLeast(GuardSeverity.MEDIUM))
|
.filter(f -> f.severity() != null && f.severity().isAtLeast(GuardSeverity.MEDIUM))
|
||||||
.limit(3)
|
.limit(3)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package vip.mate.tool.guard.guardian;
|
|||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.tool.guard.engine.ToolGuardRuleRegistry;
|
||||||
import vip.mate.tool.guard.model.*;
|
import vip.mate.tool.guard.model.*;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -14,8 +15,13 @@ import java.util.regex.Pattern;
|
|||||||
/**
|
/**
|
||||||
* 凭据泄露守卫
|
* 凭据泄露守卫
|
||||||
* <p>
|
* <p>
|
||||||
* 检测工具参数中可能包含的敏感凭据信息。
|
* 检测工具参数中可能包含的敏感凭据信息。alwaysRun=true,不受 guarded tools 范围限制。
|
||||||
* alwaysRun=true,不受 guarded tools 范围限制。
|
* <p>
|
||||||
|
* 规则优先级:
|
||||||
|
* <ol>
|
||||||
|
* <li>优先读取 DB 中 category=CREDENTIAL_EXPOSURE 的已启用规则(受 UI 开关控制);</li>
|
||||||
|
* <li>DB 无任何凭据规则时回退到内置硬编码列表(保证未初始化部署也能工作)。</li>
|
||||||
|
* </ol>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@ -23,31 +29,43 @@ public class CredentialExposureGuardian implements ToolGuardGuardian {
|
|||||||
|
|
||||||
private static final Map<String, Pattern> COMPILED = new ConcurrentHashMap<>();
|
private static final Map<String, Pattern> COMPILED = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private record CredentialRule(String ruleId, String pattern, String title, String description) {}
|
private record CredentialRule(String ruleId, String pattern, String title,
|
||||||
|
String description, GuardDecision decision) {}
|
||||||
|
|
||||||
private static final List<CredentialRule> RULES = List.of(
|
private static final List<CredentialRule> BUILTIN_FALLBACK = List.of(
|
||||||
new CredentialRule("CRED_PASSWORD_ASSIGN",
|
new CredentialRule("CRED_PASSWORD_ASSIGN",
|
||||||
"(password|secret|api[_-]?key|token)\\s*=\\s*['\"]?\\S{8,}",
|
"(password|secret|api[_-]?key|token)\\s*=\\s*['\"]?\\S{8,}",
|
||||||
"凭据信息暴露",
|
"凭据信息暴露",
|
||||||
"检测到可能的密码/密钥/Token 赋值"),
|
"检测到可能的密码/密钥/Token 赋值",
|
||||||
|
GuardDecision.NEEDS_APPROVAL),
|
||||||
new CredentialRule("CRED_AWS_KEY",
|
new CredentialRule("CRED_AWS_KEY",
|
||||||
"AKIA[0-9A-Z]{16}",
|
"AKIA[0-9A-Z]{16}",
|
||||||
"AWS Access Key 泄露",
|
"AWS Access Key 泄露",
|
||||||
"检测到 AWS Access Key ID 模式"),
|
"检测到 AWS Access Key ID 模式",
|
||||||
|
GuardDecision.NEEDS_APPROVAL),
|
||||||
new CredentialRule("CRED_PRIVATE_KEY",
|
new CredentialRule("CRED_PRIVATE_KEY",
|
||||||
"-----BEGIN\\s+(RSA\\s+)?PRIVATE\\s+KEY-----",
|
"-----BEGIN\\s+(RSA\\s+)?PRIVATE\\s+KEY-----",
|
||||||
"私钥泄露",
|
"私钥泄露",
|
||||||
"检测到 PEM 格式私钥"),
|
"检测到 PEM 格式私钥",
|
||||||
|
GuardDecision.BLOCK),
|
||||||
new CredentialRule("CRED_JWT_TOKEN",
|
new CredentialRule("CRED_JWT_TOKEN",
|
||||||
"eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]+",
|
"eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]+",
|
||||||
"JWT Token 泄露",
|
"JWT Token 泄露",
|
||||||
"检测到 JWT Token 格式的字符串"),
|
"检测到 JWT Token 格式的字符串",
|
||||||
|
GuardDecision.NEEDS_APPROVAL),
|
||||||
new CredentialRule("CRED_GITHUB_TOKEN",
|
new CredentialRule("CRED_GITHUB_TOKEN",
|
||||||
"gh[pousr]_[A-Za-z0-9_]{36,}",
|
"gh[pousr]_[A-Za-z0-9_]{36,}",
|
||||||
"GitHub Token 泄露",
|
"GitHub Token 泄露",
|
||||||
"检测到 GitHub Personal Access Token")
|
"检测到 GitHub Personal Access Token",
|
||||||
|
GuardDecision.NEEDS_APPROVAL)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private final ToolGuardRuleRegistry ruleRegistry;
|
||||||
|
|
||||||
|
public CredentialExposureGuardian(ToolGuardRuleRegistry ruleRegistry) {
|
||||||
|
this.ruleRegistry = ruleRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supports(ToolInvocationContext context) {
|
public boolean supports(ToolInvocationContext context) {
|
||||||
return true;
|
return true;
|
||||||
@ -69,7 +87,45 @@ public class CredentialExposureGuardian implements ToolGuardGuardian {
|
|||||||
if (raw == null || raw.isEmpty()) return List.of();
|
if (raw == null || raw.isEmpty()) return List.of();
|
||||||
|
|
||||||
List<GuardFinding> findings = new ArrayList<>();
|
List<GuardFinding> findings = new ArrayList<>();
|
||||||
for (CredentialRule rule : RULES) {
|
|
||||||
|
// 1) 优先使用 DB 规则(受 UI 启用/禁用开关控制)
|
||||||
|
List<ToolGuardRuleEntity> dbRules = ruleRegistry.getRulesByCategory(
|
||||||
|
GuardCategory.CREDENTIAL_EXPOSURE.name());
|
||||||
|
if (!dbRules.isEmpty()) {
|
||||||
|
for (ToolGuardRuleEntity rule : dbRules) {
|
||||||
|
if (rule.getPattern() == null || rule.getPattern().isBlank()) continue;
|
||||||
|
Pattern p = ruleRegistry.getCompiledPattern(rule.getPattern());
|
||||||
|
Matcher matcher = p.matcher(raw);
|
||||||
|
if (!matcher.find()) continue;
|
||||||
|
|
||||||
|
// 排除模式(白名单)
|
||||||
|
if (rule.getExcludePattern() != null && !rule.getExcludePattern().isBlank()) {
|
||||||
|
Pattern exclude = ruleRegistry.getCompiledExcludePattern(rule.getExcludePattern());
|
||||||
|
if (exclude.matcher(raw).find()) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String snippet = extractSnippet(raw, matcher.start(), 30);
|
||||||
|
GuardSeverity severity = parseSeverity(rule.getSeverity());
|
||||||
|
GuardDecision decision = parseDecision(rule.getDecision());
|
||||||
|
findings.add(new GuardFinding(
|
||||||
|
rule.getRuleId(),
|
||||||
|
severity,
|
||||||
|
GuardCategory.CREDENTIAL_EXPOSURE,
|
||||||
|
rule.getName(),
|
||||||
|
rule.getDescription(),
|
||||||
|
rule.getRemediation(),
|
||||||
|
context.toolName(),
|
||||||
|
rule.getParamName(),
|
||||||
|
rule.getPattern(),
|
||||||
|
maskCredential(snippet),
|
||||||
|
decision
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) DB 未初始化 → 回退内置规则
|
||||||
|
for (CredentialRule rule : BUILTIN_FALLBACK) {
|
||||||
Pattern p = COMPILED.computeIfAbsent(rule.pattern,
|
Pattern p = COMPILED.computeIfAbsent(rule.pattern,
|
||||||
r -> Pattern.compile(r, Pattern.CASE_INSENSITIVE));
|
r -> Pattern.compile(r, Pattern.CASE_INSENSITIVE));
|
||||||
Matcher matcher = p.matcher(raw);
|
Matcher matcher = p.matcher(raw);
|
||||||
@ -85,13 +141,32 @@ public class CredentialExposureGuardian implements ToolGuardGuardian {
|
|||||||
context.toolName(),
|
context.toolName(),
|
||||||
null,
|
null,
|
||||||
rule.pattern,
|
rule.pattern,
|
||||||
maskCredential(snippet)
|
maskCredential(snippet),
|
||||||
|
rule.decision
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private GuardSeverity parseSeverity(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return GuardSeverity.HIGH;
|
||||||
|
try {
|
||||||
|
return GuardSeverity.valueOf(raw);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return GuardSeverity.HIGH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GuardDecision parseDecision(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return GuardDecision.valueOf(raw);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String extractSnippet(String input, int matchStart, int contextLen) {
|
private String extractSnippet(String input, int matchStart, int contextLen) {
|
||||||
int start = Math.max(0, matchStart - contextLen / 2);
|
int start = Math.max(0, matchStart - contextLen / 2);
|
||||||
int end = Math.min(input.length(), matchStart + contextLen / 2);
|
int end = Math.min(input.length(), matchStart + contextLen / 2);
|
||||||
|
|||||||
@ -79,7 +79,8 @@ public class ShellCommandGuardian implements ToolGuardGuardian {
|
|||||||
context.toolName(),
|
context.toolName(),
|
||||||
rule.getParamName() != null ? rule.getParamName() : "command",
|
rule.getParamName() != null ? rule.getParamName() : "command",
|
||||||
rule.getPattern(),
|
rule.getPattern(),
|
||||||
snippet
|
snippet,
|
||||||
|
parseDecision(rule.getDecision())
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,6 +116,15 @@ public class ShellCommandGuardian implements ToolGuardGuardian {
|
|||||||
return (context.toolName() != null ? context.toolName() + " " : "") + raw;
|
return (context.toolName() != null ? context.toolName() + " " : "") + raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private GuardDecision parseDecision(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return GuardDecision.valueOf(raw);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String extractSnippet(String input, int matchStart, int contextLen) {
|
private String extractSnippet(String input, int matchStart, int contextLen) {
|
||||||
int start = Math.max(0, matchStart - contextLen / 2);
|
int start = Math.max(0, matchStart - contextLen / 2);
|
||||||
int end = Math.min(input.length(), matchStart + contextLen / 2);
|
int end = Math.min(input.length(), matchStart + contextLen / 2);
|
||||||
|
|||||||
@ -7,6 +7,10 @@ import java.util.Map;
|
|||||||
* <p>
|
* <p>
|
||||||
* 由 Guardian 评估产出,携带完整的威胁上下文信息。
|
* 由 Guardian 评估产出,携带完整的威胁上下文信息。
|
||||||
* 使用不可变 record,产出后不允许被修改。
|
* 使用不可变 record,产出后不允许被修改。
|
||||||
|
* <p>
|
||||||
|
* {@code decision} 为可选的"该规则希望的最终裁决"。Guardian 若从 DB 加载规则,
|
||||||
|
* 应把 rule.decision 透传过来;PolicyResolver 在聚合阶段会取所有 findings 中
|
||||||
|
* 最严格的一项作为最终 decision,未设置时回退到 severity → 默认动作。
|
||||||
*/
|
*/
|
||||||
public record GuardFinding(
|
public record GuardFinding(
|
||||||
String ruleId,
|
String ruleId,
|
||||||
@ -19,6 +23,7 @@ public record GuardFinding(
|
|||||||
String paramName,
|
String paramName,
|
||||||
String matchedPattern,
|
String matchedPattern,
|
||||||
String snippet,
|
String snippet,
|
||||||
|
GuardDecision decision,
|
||||||
Map<String, Object> metadata
|
Map<String, Object> metadata
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@ -26,7 +31,15 @@ public record GuardFinding(
|
|||||||
String title, String description, String remediation,
|
String title, String description, String remediation,
|
||||||
String toolName, String paramName, String matchedPattern, String snippet) {
|
String toolName, String paramName, String matchedPattern, String snippet) {
|
||||||
this(ruleId, severity, category, title, description, remediation,
|
this(ruleId, severity, category, title, description, remediation,
|
||||||
toolName, paramName, matchedPattern, snippet, Map.of());
|
toolName, paramName, matchedPattern, snippet, null, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public GuardFinding(String ruleId, GuardSeverity severity, GuardCategory category,
|
||||||
|
String title, String description, String remediation,
|
||||||
|
String toolName, String paramName, String matchedPattern, String snippet,
|
||||||
|
GuardDecision decision) {
|
||||||
|
this(ruleId, severity, category, title, description, remediation,
|
||||||
|
toolName, paramName, matchedPattern, snippet, decision, Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,7 +56,8 @@ public record GuardFinding(
|
|||||||
Map.entry("toolName", toolName != null ? toolName : ""),
|
Map.entry("toolName", toolName != null ? toolName : ""),
|
||||||
Map.entry("paramName", paramName != null ? paramName : ""),
|
Map.entry("paramName", paramName != null ? paramName : ""),
|
||||||
Map.entry("matchedPattern", matchedPattern != null ? matchedPattern : ""),
|
Map.entry("matchedPattern", matchedPattern != null ? matchedPattern : ""),
|
||||||
Map.entry("snippet", snippet != null ? snippet : "")
|
Map.entry("snippet", snippet != null ? snippet : ""),
|
||||||
|
Map.entry("decision", decision != null ? decision.name() : "")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -169,19 +169,18 @@ public class ToolGuardRuleSeedService implements ApplicationRunner {
|
|||||||
log.debug("[RuleSeed] Rule {} insert failed: {}", rule.getRuleId(), e.getMessage());
|
log.debug("[RuleSeed] Rule {} insert failed: {}", rule.getRuleId(), e.getMessage());
|
||||||
}
|
}
|
||||||
} else if (needsUpdate(existing, rule)) {
|
} else if (needsUpdate(existing, rule)) {
|
||||||
// 已存在但内容有变化 → 更新
|
// 已存在但内容字段有变化 → 同步代码侧拥有的字段(content fields)。
|
||||||
|
// 严格保留用户侧拥有的策略字段(severity / decision / priority / enabled
|
||||||
|
// / excludePattern)—— 这些一旦用户在 UI 上调整,重启后不应被覆盖。
|
||||||
ruleMapper.update(null,
|
ruleMapper.update(null,
|
||||||
new LambdaUpdateWrapper<ToolGuardRuleEntity>()
|
new LambdaUpdateWrapper<ToolGuardRuleEntity>()
|
||||||
.eq(ToolGuardRuleEntity::getRuleId, rule.getRuleId())
|
.eq(ToolGuardRuleEntity::getRuleId, rule.getRuleId())
|
||||||
.set(ToolGuardRuleEntity::getName, rule.getName())
|
.set(ToolGuardRuleEntity::getName, rule.getName())
|
||||||
.set(ToolGuardRuleEntity::getDescription, rule.getDescription())
|
.set(ToolGuardRuleEntity::getDescription, rule.getDescription())
|
||||||
.set(ToolGuardRuleEntity::getPattern, rule.getPattern())
|
.set(ToolGuardRuleEntity::getPattern, rule.getPattern())
|
||||||
.set(ToolGuardRuleEntity::getSeverity, rule.getSeverity())
|
|
||||||
.set(ToolGuardRuleEntity::getCategory, rule.getCategory())
|
.set(ToolGuardRuleEntity::getCategory, rule.getCategory())
|
||||||
.set(ToolGuardRuleEntity::getDecision, rule.getDecision())
|
|
||||||
.set(ToolGuardRuleEntity::getToolName, rule.getToolName())
|
.set(ToolGuardRuleEntity::getToolName, rule.getToolName())
|
||||||
.set(ToolGuardRuleEntity::getRemediation, rule.getRemediation())
|
.set(ToolGuardRuleEntity::getRemediation, rule.getRemediation()));
|
||||||
.set(ToolGuardRuleEntity::getPriority, rule.getPriority()));
|
|
||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
unchanged++;
|
unchanged++;
|
||||||
@ -195,16 +194,21 @@ public class ToolGuardRuleSeedService implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断已有 builtin 规则是否需要更新(任一核心字段有变化即需要)
|
* 判断已有 builtin 规则是否需要更新。
|
||||||
|
* <p>
|
||||||
|
* 只比较"内容字段"(代码侧拥有,应当随版本升级同步):
|
||||||
|
* name / description / pattern / category / toolName / remediation。
|
||||||
|
* <p>
|
||||||
|
* 故意不比较"策略字段"(用户侧拥有,UI 可调):severity / decision / priority / enabled / excludePattern。
|
||||||
|
* 这样用户把某条 builtin 规则的 decision 从 NEEDS_APPROVAL 改成 BLOCK、或者关闭某条规则,
|
||||||
|
* 重启不会把改动覆盖回种子初值。
|
||||||
*/
|
*/
|
||||||
private boolean needsUpdate(ToolGuardRuleEntity existing, ToolGuardRuleEntity expected) {
|
private boolean needsUpdate(ToolGuardRuleEntity existing, ToolGuardRuleEntity expected) {
|
||||||
return !Objects.equals(existing.getPattern(), expected.getPattern())
|
return !Objects.equals(existing.getName(), expected.getName())
|
||||||
|| !Objects.equals(existing.getSeverity(), expected.getSeverity())
|
|| !Objects.equals(existing.getDescription(), expected.getDescription())
|
||||||
|
|| !Objects.equals(existing.getPattern(), expected.getPattern())
|
||||||
|| !Objects.equals(existing.getCategory(), expected.getCategory())
|
|| !Objects.equals(existing.getCategory(), expected.getCategory())
|
||||||
|| !Objects.equals(existing.getDecision(), expected.getDecision())
|
|
||||||
|| !Objects.equals(existing.getToolName(), expected.getToolName())
|
|| !Objects.equals(existing.getToolName(), expected.getToolName())
|
||||||
|| !Objects.equals(existing.getPriority(), expected.getPriority())
|
|
||||||
|| !Objects.equals(existing.getName(), expected.getName())
|
|
||||||
|| !Objects.equals(existing.getRemediation(), expected.getRemediation());
|
|| !Objects.equals(existing.getRemediation(), expected.getRemediation());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -330,6 +334,16 @@ public class ToolGuardRuleSeedService implements ApplicationRunner {
|
|||||||
GuardSeverity.HIGH, GuardCategory.CREDENTIAL_EXPOSURE, "BLOCK",
|
GuardSeverity.HIGH, GuardCategory.CREDENTIAL_EXPOSURE, "BLOCK",
|
||||||
null, gf("CRED_PRIVATE_KEY"), 140));
|
null, gf("CRED_PRIVATE_KEY"), 140));
|
||||||
|
|
||||||
|
rules.add(rule("CRED_JWT_TOKEN", gn("CRED_JWT_TOKEN"),
|
||||||
|
"eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]+",
|
||||||
|
GuardSeverity.HIGH, GuardCategory.CREDENTIAL_EXPOSURE, "NEEDS_APPROVAL",
|
||||||
|
null, gf("CRED_JWT_TOKEN"), 140));
|
||||||
|
|
||||||
|
rules.add(rule("CRED_GITHUB_TOKEN", gn("CRED_GITHUB_TOKEN"),
|
||||||
|
"gh[pousr]_[A-Za-z0-9_]{36,}",
|
||||||
|
GuardSeverity.HIGH, GuardCategory.CREDENTIAL_EXPOSURE, "NEEDS_APPROVAL",
|
||||||
|
null, gf("CRED_GITHUB_TOKEN"), 140));
|
||||||
|
|
||||||
return rules;
|
return rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,11 @@ import vip.mate.tool.guard.engine.ToolGuardRuleRegistry;
|
|||||||
import vip.mate.tool.guard.model.ToolGuardRuleEntity;
|
import vip.mate.tool.guard.model.ToolGuardRuleEntity;
|
||||||
import vip.mate.tool.guard.repository.ToolGuardRuleMapper;
|
import vip.mate.tool.guard.repository.ToolGuardRuleMapper;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具安全规则 CRUD 服务
|
* 工具安全规则 CRUD 服务
|
||||||
*/
|
*/
|
||||||
@ -94,6 +99,11 @@ public class ToolGuardRuleService {
|
|||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
throw new IllegalArgumentException("Rule not found: " + ruleId);
|
throw new IllegalArgumentException("Rule not found: " + ruleId);
|
||||||
}
|
}
|
||||||
|
// 内置规则只允许调整策略字段;内容字段(pattern / category / name 等)由
|
||||||
|
// 代码侧种子管理,写进 DB 也会在下次启动被覆盖回去,提前在这里拦掉以免误用。
|
||||||
|
if (Boolean.TRUE.equals(existing.getBuiltin())) {
|
||||||
|
return updateBuiltinPolicy(ruleId, update);
|
||||||
|
}
|
||||||
|
|
||||||
if (update.getName() != null) {
|
if (update.getName() != null) {
|
||||||
requireNonBlank(update.getName(), "Rule name");
|
requireNonBlank(update.getName(), "Rule name");
|
||||||
@ -153,6 +163,130 @@ public class ToolGuardRuleService {
|
|||||||
ruleRegistry.reload();
|
ruleRegistry.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新内置规则时,限制只允许调整策略字段(severity / decision / priority / enabled
|
||||||
|
* / excludePattern)。内容字段(pattern / category / name 等)由代码侧种子管理,
|
||||||
|
* UI 改动也会在下次重启被覆盖回去,提前在 API 层拦截避免误用。
|
||||||
|
*/
|
||||||
|
public ToolGuardRuleEntity updateBuiltinPolicy(String ruleId, ToolGuardRuleEntity patch) {
|
||||||
|
ToolGuardRuleEntity existing = getByRuleId(ruleId);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new IllegalArgumentException("Rule not found: " + ruleId);
|
||||||
|
}
|
||||||
|
if (!Boolean.TRUE.equals(existing.getBuiltin())) {
|
||||||
|
throw new IllegalArgumentException("Rule is not builtin: " + ruleId);
|
||||||
|
}
|
||||||
|
if (patch.getSeverity() != null) existing.setSeverity(patch.getSeverity());
|
||||||
|
if (patch.getDecision() != null) existing.setDecision(patch.getDecision());
|
||||||
|
if (patch.getPriority() != null) existing.setPriority(patch.getPriority());
|
||||||
|
if (patch.getEnabled() != null) existing.setEnabled(patch.getEnabled());
|
||||||
|
if (patch.getExcludePattern() != null) existing.setExcludePattern(patch.getExcludePattern());
|
||||||
|
ruleMapper.updateById(existing);
|
||||||
|
ruleRegistry.reload();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出全部规则(含 builtin),格式可被 importRules 直接吃回去。
|
||||||
|
* 导出时保留 ruleId 作为主键标识,省略 id / createTime / updateTime / deleted 这些
|
||||||
|
* 部署敏感的字段;builtin 标志保留,import 时用来判断走 builtin policy 通道还是
|
||||||
|
* 创建/覆盖 custom 规则。
|
||||||
|
*/
|
||||||
|
public Map<String, Object> exportRules() {
|
||||||
|
List<ToolGuardRuleEntity> all = ruleMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<ToolGuardRuleEntity>()
|
||||||
|
.orderByDesc(ToolGuardRuleEntity::getPriority));
|
||||||
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
for (ToolGuardRuleEntity r : all) {
|
||||||
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("ruleId", r.getRuleId());
|
||||||
|
row.put("name", r.getName());
|
||||||
|
row.put("description", r.getDescription());
|
||||||
|
row.put("toolName", r.getToolName());
|
||||||
|
row.put("paramName", r.getParamName());
|
||||||
|
row.put("category", r.getCategory());
|
||||||
|
row.put("severity", r.getSeverity());
|
||||||
|
row.put("decision", r.getDecision());
|
||||||
|
row.put("pattern", r.getPattern());
|
||||||
|
row.put("excludePattern", r.getExcludePattern());
|
||||||
|
row.put("remediation", r.getRemediation());
|
||||||
|
row.put("priority", r.getPriority());
|
||||||
|
row.put("enabled", r.getEnabled());
|
||||||
|
row.put("builtin", r.getBuiltin());
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
Map<String, Object> envelope = new LinkedHashMap<>();
|
||||||
|
envelope.put("schema", "mateclaw.tool-guard.rules.v1");
|
||||||
|
envelope.put("exportedAt", java.time.OffsetDateTime.now().toString());
|
||||||
|
envelope.put("count", rows.size());
|
||||||
|
envelope.put("rules", rows);
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入规则。upsert 语义:
|
||||||
|
* <ul>
|
||||||
|
* <li>ruleId 已存在 + builtin → 仅同步策略字段(severity / decision / priority / enabled / excludePattern);</li>
|
||||||
|
* <li>ruleId 已存在 + custom → 全字段覆盖;</li>
|
||||||
|
* <li>ruleId 不存在 → 作为 custom 规则插入(强制 builtin=false,避免被 import 篡改内置标记)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public Map<String, Object> importRules(List<ToolGuardRuleEntity> incoming) {
|
||||||
|
if (incoming == null || incoming.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("No rules to import");
|
||||||
|
}
|
||||||
|
int inserted = 0;
|
||||||
|
int updatedBuiltin = 0;
|
||||||
|
int updatedCustom = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ToolGuardRuleEntity rule : incoming) {
|
||||||
|
try {
|
||||||
|
if (rule.getRuleId() == null || rule.getRuleId().isBlank()) {
|
||||||
|
skipped++;
|
||||||
|
errors.add("missing ruleId");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.getPattern() == null || rule.getPattern().isBlank()) {
|
||||||
|
skipped++;
|
||||||
|
errors.add(rule.getRuleId() + ": missing pattern");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String rid = rule.getRuleId().trim();
|
||||||
|
ToolGuardRuleEntity existing = getByRuleId(rid);
|
||||||
|
if (existing == null) {
|
||||||
|
rule.setRuleId(rid);
|
||||||
|
rule.setBuiltin(false);
|
||||||
|
if (rule.getEnabled() == null) rule.setEnabled(true);
|
||||||
|
if (rule.getPriority() == null) rule.setPriority(100);
|
||||||
|
if (rule.getSeverity() == null) rule.setSeverity("HIGH");
|
||||||
|
if (rule.getDecision() == null) rule.setDecision("NEEDS_APPROVAL");
|
||||||
|
ruleMapper.insert(rule);
|
||||||
|
inserted++;
|
||||||
|
} else if (Boolean.TRUE.equals(existing.getBuiltin())) {
|
||||||
|
updateBuiltinPolicy(rid, rule);
|
||||||
|
updatedBuiltin++;
|
||||||
|
} else {
|
||||||
|
updateRule(rid, rule);
|
||||||
|
updatedCustom++;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
skipped++;
|
||||||
|
errors.add((rule.getRuleId() == null ? "<no id>" : rule.getRuleId())
|
||||||
|
+ ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ruleRegistry.reload();
|
||||||
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
|
summary.put("inserted", inserted);
|
||||||
|
summary.put("updatedBuiltin", updatedBuiltin);
|
||||||
|
summary.put("updatedCustom", updatedCustom);
|
||||||
|
summary.put("skipped", skipped);
|
||||||
|
summary.put("errors", errors);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按主键 ID 删除自定义规则。兜底通道:当 rule_id 因历史脏数据为空或无法走
|
* 按主键 ID 删除自定义规则。兜底通道:当 rule_id 因历史脏数据为空或无法走
|
||||||
* /guard/rules/{ruleId} 路径变量时,UI 仍可通过主键删除。
|
* /guard/rules/{ruleId} 路径变量时,UI 仍可通过主键删除。
|
||||||
|
|||||||
@ -154,6 +154,10 @@ guard.CRED_AWS_KEY.name=AWS Access Key \u6cc4\u9732
|
|||||||
guard.CRED_AWS_KEY.fix=\u4f7f\u7528 IAM Role \u6216 AWS Secrets Manager
|
guard.CRED_AWS_KEY.fix=\u4f7f\u7528 IAM Role \u6216 AWS Secrets Manager
|
||||||
guard.CRED_PRIVATE_KEY.name=\u79c1\u94a5\u6cc4\u9732
|
guard.CRED_PRIVATE_KEY.name=\u79c1\u94a5\u6cc4\u9732
|
||||||
guard.CRED_PRIVATE_KEY.fix=\u8bf7\u52ff\u5728\u53c2\u6570\u4e2d\u4f20\u9012\u79c1\u94a5
|
guard.CRED_PRIVATE_KEY.fix=\u8bf7\u52ff\u5728\u53c2\u6570\u4e2d\u4f20\u9012\u79c1\u94a5
|
||||||
|
guard.CRED_JWT_TOKEN.name=JWT Token \u6cc4\u9732
|
||||||
|
guard.CRED_JWT_TOKEN.fix=\u8bf7\u4f7f\u7528\u73af\u5883\u53d8\u91cf\u6216\u5bc6\u94a5\u7ba1\u7406\u670d\u52a1\uff0c\u907f\u514d\u660e\u6587\u4f20\u9012 JWT
|
||||||
|
guard.CRED_GITHUB_TOKEN.name=GitHub Token \u6cc4\u9732
|
||||||
|
guard.CRED_GITHUB_TOKEN.fix=\u8bf7\u7acb\u5373\u5728 GitHub \u540e\u53f0\u64a4\u9500\u8be5 Token\uff0c\u6539\u7528\u73af\u5883\u53d8\u91cf
|
||||||
|
|
||||||
# --- Exception Messages (structured keys) ---
|
# --- Exception Messages (structured keys) ---
|
||||||
err.auth.invalid_credentials=\u7528\u6237\u540d\u6216\u5bc6\u7801\u9519\u8bef
|
err.auth.invalid_credentials=\u7528\u6237\u540d\u6216\u5bc6\u7801\u9519\u8bef
|
||||||
|
|||||||
@ -154,6 +154,10 @@ guard.CRED_AWS_KEY.name=AWS Access Key leak
|
|||||||
guard.CRED_AWS_KEY.fix=Use IAM Role or AWS Secrets Manager
|
guard.CRED_AWS_KEY.fix=Use IAM Role or AWS Secrets Manager
|
||||||
guard.CRED_PRIVATE_KEY.name=Private key leak
|
guard.CRED_PRIVATE_KEY.name=Private key leak
|
||||||
guard.CRED_PRIVATE_KEY.fix=Do not pass private keys in parameters
|
guard.CRED_PRIVATE_KEY.fix=Do not pass private keys in parameters
|
||||||
|
guard.CRED_JWT_TOKEN.name=JWT token leak
|
||||||
|
guard.CRED_JWT_TOKEN.fix=Use environment variables or secret manager; avoid passing JWTs in plaintext
|
||||||
|
guard.CRED_GITHUB_TOKEN.name=GitHub token leak
|
||||||
|
guard.CRED_GITHUB_TOKEN.fix=Revoke the token in GitHub immediately and switch to environment variables
|
||||||
|
|
||||||
# --- WorkspacePathGuard ---
|
# --- WorkspacePathGuard ---
|
||||||
guard.path.not_allowed=Path is outside workspace boundary: {0}, allowed root: {1}
|
guard.path.not_allowed=Path is outside workspace boundary: {0}, allowed root: {1}
|
||||||
|
|||||||
@ -58,6 +58,7 @@ class ChannelManagerReconcileTest {
|
|||||||
mock(vip.mate.channel.wecom.WeComKeepaliveScheduler.class),
|
mock(vip.mate.channel.wecom.WeComKeepaliveScheduler.class),
|
||||||
mock(vip.mate.channel.feishu.FeishuMediaUploader.class),
|
mock(vip.mate.channel.feishu.FeishuMediaUploader.class),
|
||||||
mock(vip.mate.channel.media.GeneratedFileScrubber.class),
|
mock(vip.mate.channel.media.GeneratedFileScrubber.class),
|
||||||
|
mock(vip.mate.channel.feishu.FeishuStreamingCardManager.class),
|
||||||
election);
|
election);
|
||||||
adapter = new TrackingAdapter();
|
adapter = new TrackingAdapter();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ class FeishuMediaWiringIT {
|
|||||||
@Autowired private FeishuMediaUploader mediaUploader;
|
@Autowired private FeishuMediaUploader mediaUploader;
|
||||||
@Autowired private FeishuSizePolicy sizePolicy;
|
@Autowired private FeishuSizePolicy sizePolicy;
|
||||||
@Autowired private GeneratedFileScrubber scrubber;
|
@Autowired private GeneratedFileScrubber scrubber;
|
||||||
|
@Autowired private FeishuStreamingCardManager streamingCardManager;
|
||||||
@Autowired private List<MediaUploader> uploaderBeans;
|
@Autowired private List<MediaUploader> uploaderBeans;
|
||||||
@Autowired private List<MediaSizePolicy> policyBeans;
|
@Autowired private List<MediaSizePolicy> policyBeans;
|
||||||
|
|
||||||
@ -43,6 +44,7 @@ class FeishuMediaWiringIT {
|
|||||||
assertNotNull(mediaUploader);
|
assertNotNull(mediaUploader);
|
||||||
assertNotNull(sizePolicy);
|
assertNotNull(sizePolicy);
|
||||||
assertNotNull(scrubber);
|
assertNotNull(scrubber);
|
||||||
|
assertNotNull(streamingCardManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -0,0 +1,256 @@
|
|||||||
|
package vip.mate.channel.feishu;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.lark.oapi.Client;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the FeishuStreamingCardManager state machine + throttle.
|
||||||
|
*
|
||||||
|
* <p>SDK calls are stubbed via {@code protected} seams so this test
|
||||||
|
* stays Spring-free and network-free — we verify ordering and
|
||||||
|
* lifecycle, not Feishu API shape (the IT covers wiring).
|
||||||
|
*
|
||||||
|
* <p>Behaviour pinned:
|
||||||
|
* <ul>
|
||||||
|
* <li>throttle window suppresses sub-window flushes; forceFlush
|
||||||
|
* bypasses it; finishCard always flushes</li>
|
||||||
|
* <li>session is removed from {@code activeSessions} on terminal
|
||||||
|
* transition; subsequent appends are no-ops</li>
|
||||||
|
* <li>finish vs fail is a CAS-guarded one-shot — second terminal
|
||||||
|
* call is silent and does not double-close streaming</li>
|
||||||
|
* <li>sequence numbers are monotonic across content + close</li>
|
||||||
|
* <li>initial card JSON carries schema 2.0 + streaming_mode + the
|
||||||
|
* agreed element id</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
class FeishuStreamingCardManagerTest {
|
||||||
|
|
||||||
|
/** Recording SDK seam — captures each call so the test can replay them. */
|
||||||
|
private static final class RecordingManager extends FeishuStreamingCardManager {
|
||||||
|
record ContentCall(String cardId, String elementId, String content, int sequence) {}
|
||||||
|
record CloseCall(String cardId, int sequence) {}
|
||||||
|
|
||||||
|
final List<ContentCall> contentCalls = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||||
|
final List<CloseCall> closeCalls = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||||
|
final AtomicLong fakeNowMs = new AtomicLong(0);
|
||||||
|
final AtomicReference<String> nextCardId = new AtomicReference<>("card_abc");
|
||||||
|
final AtomicReference<String> nextMessageId = new AtomicReference<>("msg_abc");
|
||||||
|
|
||||||
|
RecordingManager(FeishuClientFactory factory, ObjectMapper objectMapper) {
|
||||||
|
super(factory, objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected long currentTimeMs() { return fakeNowMs.get(); }
|
||||||
|
|
||||||
|
@Override protected String sdkCreateCard(Client client, String initialText) { return nextCardId.get(); }
|
||||||
|
|
||||||
|
@Override protected String sdkSendInteractiveMessage(Client client, String receiveIdType,
|
||||||
|
String receiveId, String cardId) {
|
||||||
|
return nextMessageId.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void sdkPushElementContent(Client client, String cardId, String elementId,
|
||||||
|
String content, int sequence) {
|
||||||
|
contentCalls.add(new ContentCall(cardId, elementId, content, sequence));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void sdkCloseStreamingMode(Client client, String cardId, int sequence) {
|
||||||
|
closeCalls.add(new CloseCall(cardId, sequence));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecordingManager manager;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
FeishuClientFactory factory = mock(FeishuClientFactory.class);
|
||||||
|
when(factory.client(anyLong())).thenReturn(mock(Client.class));
|
||||||
|
// Mockito.any() also returns mock Client for boxed Long lookups
|
||||||
|
when(factory.client(any())).thenReturn(mock(Client.class));
|
||||||
|
manager = new RecordingManager(factory, new ObjectMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("createAndDeliver registers a session keyed by UUID")
|
||||||
|
void createAndDeliverRegistersSession() {
|
||||||
|
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
|
||||||
|
assertNotNull(key);
|
||||||
|
assertEquals(1, manager.activeSessionCount());
|
||||||
|
assertNotNull(manager.sessionFor(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("createAndDeliver returns null when card creation fails")
|
||||||
|
void createReturnsNullOnFailure() {
|
||||||
|
manager.nextCardId.set(null);
|
||||||
|
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
|
||||||
|
assertNull(key);
|
||||||
|
assertEquals(0, manager.activeSessionCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("the very first append flushes immediately (no prior flush gates)")
|
||||||
|
void firstAppendFlushesImmediately() {
|
||||||
|
manager.fakeNowMs.set(1000L);
|
||||||
|
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
|
||||||
|
|
||||||
|
manager.fakeNowMs.set(1010L); // only 10ms later — still flushes because no prior flush
|
||||||
|
manager.appendContent(key, "Hel", false);
|
||||||
|
|
||||||
|
assertEquals(1, manager.contentCalls.size(),
|
||||||
|
"First-ever append should flush immediately so the user sees an instant first token");
|
||||||
|
assertEquals("Hel", manager.contentCalls.get(0).content());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("subsequent appends inside the throttle window are suppressed and accumulated")
|
||||||
|
void throttleSuppressesPostFirstFlushAppends() {
|
||||||
|
manager.fakeNowMs.set(1000L);
|
||||||
|
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
|
||||||
|
|
||||||
|
// First — flushes immediately (seq 1) and sets lastFlushMs=1010
|
||||||
|
manager.fakeNowMs.set(1010L);
|
||||||
|
manager.appendContent(key, "first ", false);
|
||||||
|
assertEquals(1, manager.contentCalls.size());
|
||||||
|
|
||||||
|
// Inside 500ms window → suppressed, just accumulated
|
||||||
|
manager.fakeNowMs.set(1100L);
|
||||||
|
manager.appendContent(key, "mid ", false);
|
||||||
|
manager.fakeNowMs.set(1400L);
|
||||||
|
manager.appendContent(key, "more ", false);
|
||||||
|
assertEquals(1, manager.contentCalls.size(),
|
||||||
|
"Mid-window appends should NOT trigger SDK calls");
|
||||||
|
|
||||||
|
// Past 500ms window → flushes the full accumulator (seq 2)
|
||||||
|
manager.fakeNowMs.set(1600L);
|
||||||
|
manager.appendContent(key, "end", false);
|
||||||
|
assertEquals(2, manager.contentCalls.size());
|
||||||
|
assertEquals("first mid more end", manager.contentCalls.get(1).content());
|
||||||
|
assertEquals(2, manager.contentCalls.get(1).sequence());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("forceFlush bypasses the throttle even inside the window")
|
||||||
|
void forceFlushBypassesThrottle() {
|
||||||
|
manager.fakeNowMs.set(0L);
|
||||||
|
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
|
||||||
|
|
||||||
|
// First append flushes regardless (lastFlushMs=0)
|
||||||
|
manager.appendContent(key, "a", false);
|
||||||
|
assertEquals(1, manager.contentCalls.size());
|
||||||
|
|
||||||
|
// 50ms later (inside 500ms window) — without force this would be suppressed
|
||||||
|
manager.fakeNowMs.set(50L);
|
||||||
|
manager.appendContent(key, "b", true);
|
||||||
|
assertEquals(2, manager.contentCalls.size(), "forceFlush must bypass throttle");
|
||||||
|
assertEquals("ab", manager.contentCalls.get(1).content());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("finishCard emits final content + close, in monotonic sequence order, then removes session")
|
||||||
|
void finishCardClosesAndUnregisters() {
|
||||||
|
manager.fakeNowMs.set(0L);
|
||||||
|
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
|
||||||
|
|
||||||
|
manager.appendContent(key, "Hello, ", true); // seq 1
|
||||||
|
manager.fakeNowMs.set(600L);
|
||||||
|
manager.appendContent(key, "world", false); // seq 2
|
||||||
|
manager.finishCard(key, "Hello, world!"); // seq 3 (content) + seq 4 (close)
|
||||||
|
|
||||||
|
assertEquals(3, manager.contentCalls.size());
|
||||||
|
assertEquals("Hello, world!", manager.contentCalls.get(2).content());
|
||||||
|
// Sequence is monotonic across all content + close calls
|
||||||
|
assertEquals(1, manager.contentCalls.get(0).sequence());
|
||||||
|
assertEquals(2, manager.contentCalls.get(1).sequence());
|
||||||
|
assertEquals(3, manager.contentCalls.get(2).sequence());
|
||||||
|
assertEquals(1, manager.closeCalls.size());
|
||||||
|
assertEquals(4, manager.closeCalls.get(0).sequence());
|
||||||
|
assertEquals(0, manager.activeSessionCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("appendContent after finish is a no-op (no SDK call, no resurrection)")
|
||||||
|
void appendAfterFinishIsNoop() {
|
||||||
|
manager.fakeNowMs.set(0L);
|
||||||
|
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
|
||||||
|
manager.finishCard(key, "done");
|
||||||
|
|
||||||
|
int before = manager.contentCalls.size();
|
||||||
|
manager.fakeNowMs.set(10_000L);
|
||||||
|
manager.appendContent(key, "ghost delta", true);
|
||||||
|
assertEquals(before, manager.contentCalls.size(),
|
||||||
|
"Append after terminal status must not produce another SDK call");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("failCard appends error suffix, closes, and ignores second terminal call")
|
||||||
|
void failCardAppendsTailAndIsOneShot() {
|
||||||
|
manager.fakeNowMs.set(0L);
|
||||||
|
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
|
||||||
|
manager.appendContent(key, "Partial reply", true);
|
||||||
|
|
||||||
|
manager.failCard(key, "rate limited");
|
||||||
|
|
||||||
|
// last content push carries the failure suffix
|
||||||
|
assertTrue(manager.contentCalls.get(manager.contentCalls.size() - 1).content().contains("rate limited"));
|
||||||
|
assertEquals(1, manager.closeCalls.size());
|
||||||
|
|
||||||
|
// A subsequent finishCard must NOT trigger another close
|
||||||
|
manager.finishCard(key, "ignored");
|
||||||
|
assertEquals(1, manager.closeCalls.size(), "Second terminal call must be ignored");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("failCard with no accumulator emits a stand-alone error message")
|
||||||
|
void failCardWithEmptyAccumulator() {
|
||||||
|
manager.fakeNowMs.set(0L);
|
||||||
|
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
|
||||||
|
|
||||||
|
manager.failCard(key, "network down");
|
||||||
|
assertEquals(1, manager.contentCalls.size());
|
||||||
|
assertTrue(manager.contentCalls.get(0).content().contains("network down"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("initial card JSON declares schema 2.0 + streaming_mode + the stream element id")
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void initialCardJsonShape() {
|
||||||
|
Map<String, Object> card = manager.buildInitialCardJson("test");
|
||||||
|
assertEquals("2.0", card.get("schema"));
|
||||||
|
Map<String, Object> config = (Map<String, Object>) card.get("config");
|
||||||
|
assertEquals(Boolean.TRUE, config.get("streaming_mode"));
|
||||||
|
Map<String, Object> body = (Map<String, Object>) card.get("body");
|
||||||
|
List<Map<String, Object>> elements = (List<Map<String, Object>>) body.get("elements");
|
||||||
|
assertEquals(FeishuStreamingCardManager.STREAM_ELEMENT_ID, elements.get(0).get("element_id"));
|
||||||
|
assertEquals("markdown", elements.get(0).get("tag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("unknown session key is a silent no-op on every public method")
|
||||||
|
void unknownSessionIsNoop() {
|
||||||
|
manager.appendContent("never-existed", "x", true);
|
||||||
|
manager.finishCard("never-existed", "x");
|
||||||
|
manager.failCard("never-existed", "x");
|
||||||
|
assertEquals(0, manager.contentCalls.size());
|
||||||
|
assertEquals(0, manager.closeCalls.size());
|
||||||
|
assertFalse(false); // assertion just to make junit happy with no real check
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -671,6 +671,8 @@ export const securityApi = {
|
|||||||
toggleRule: (ruleId: string, enabled: boolean) =>
|
toggleRule: (ruleId: string, enabled: boolean) =>
|
||||||
http.put(`/security/guard/rules/${ruleId}/toggle?enabled=${enabled}`),
|
http.put(`/security/guard/rules/${ruleId}/toggle?enabled=${enabled}`),
|
||||||
deleteRule: (ruleId: string) => http.delete(`/security/guard/rules/${ruleId}`),
|
deleteRule: (ruleId: string) => http.delete(`/security/guard/rules/${ruleId}`),
|
||||||
|
exportRules: () => http.get('/security/guard/rules/export'),
|
||||||
|
importRules: (data: { rules: any[] }) => http.post('/security/guard/rules/import', data),
|
||||||
listAuditLogs: (params?: any) => http.get('/security/audit/logs', { params }),
|
listAuditLogs: (params?: any) => http.get('/security/audit/logs', { params }),
|
||||||
getAuditStats: () => http.get('/security/audit/stats'),
|
getAuditStats: () => http.get('/security/audit/stats'),
|
||||||
listApprovals: (params?: any) => http.get('/security/approvals', { params }),
|
listApprovals: (params?: any) => http.get('/security/approvals', { params }),
|
||||||
|
|||||||
@ -1457,6 +1457,13 @@ export default {
|
|||||||
ruleIdDuplicate: 'Rule ID already exists',
|
ruleIdDuplicate: 'Rule ID already exists',
|
||||||
saveFailed: 'Failed to save rule',
|
saveFailed: 'Failed to save rule',
|
||||||
},
|
},
|
||||||
|
exportBtn: 'Export JSON',
|
||||||
|
importBtn: 'Import JSON',
|
||||||
|
exportFileName: 'mateclaw-guard-rules-{date}.json',
|
||||||
|
importPromptHint: 'Upload a JSON exported by this UI. Built-in rules only sync their policy fields.',
|
||||||
|
importSummary: 'Import done: {inserted} added, {updatedBuiltin} built-in updated, {updatedCustom} custom updated, {skipped} skipped',
|
||||||
|
importFailed: 'Import failed: {msg}',
|
||||||
|
builtinLockedHint: 'Built-in rule name, pattern and category are system-managed. You can only adjust severity, decision, priority, exclude pattern and enabled state.',
|
||||||
},
|
},
|
||||||
fileGuard: {
|
fileGuard: {
|
||||||
title: 'File Guard',
|
title: 'File Guard',
|
||||||
|
|||||||
@ -1349,6 +1349,13 @@ export default {
|
|||||||
ruleIdDuplicate: '规则 ID 已存在',
|
ruleIdDuplicate: '规则 ID 已存在',
|
||||||
saveFailed: '保存规则失败',
|
saveFailed: '保存规则失败',
|
||||||
},
|
},
|
||||||
|
exportBtn: '导出 JSON',
|
||||||
|
importBtn: '导入 JSON',
|
||||||
|
exportFileName: 'mateclaw-guard-rules-{date}.json',
|
||||||
|
importPromptHint: '上传通过"导出"产生的 JSON 文件;内置规则只会同步策略字段。',
|
||||||
|
importSummary: '导入完成:新增 {inserted},更新内置 {updatedBuiltin},更新自定义 {updatedCustom},跳过 {skipped}',
|
||||||
|
importFailed: '导入失败:{msg}',
|
||||||
|
builtinLockedHint: '内置规则的名称、模式与分类由系统管理,此处仅可调整严重度、决策、优先级、白名单与启用状态。',
|
||||||
},
|
},
|
||||||
fileGuard: {
|
fileGuard: {
|
||||||
title: '文件防护',
|
title: '文件防护',
|
||||||
|
|||||||
@ -5,9 +5,24 @@
|
|||||||
<h2 class="section-title">{{ t('security.toolGuard.title') }}</h2>
|
<h2 class="section-title">{{ t('security.toolGuard.title') }}</h2>
|
||||||
<p class="section-desc">{{ t('security.toolGuard.desc') }}</p>
|
<p class="section-desc">{{ t('security.toolGuard.desc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-primary" @click="openCreateRuleModal">
|
<div class="header-actions">
|
||||||
{{ t('security.toolGuard.addRule') }}
|
<button class="btn-secondary" @click="exportRules">
|
||||||
</button>
|
{{ t('security.toolGuard.exportBtn') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" @click="triggerImport">
|
||||||
|
{{ t('security.toolGuard.importBtn') }}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="importFileInput"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
style="display:none"
|
||||||
|
@change="onImportFile"
|
||||||
|
/>
|
||||||
|
<button class="btn-primary" @click="openCreateRuleModal">
|
||||||
|
{{ t('security.toolGuard.addRule') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global Config -->
|
<!-- Global Config -->
|
||||||
@ -144,6 +159,9 @@
|
|||||||
<button class="modal-close" @click="showRuleModal = false">×</button>
|
<button class="modal-close" @click="showRuleModal = false">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<div v-if="editingBuiltin" class="builtin-hint">
|
||||||
|
{{ t('security.toolGuard.builtinLockedHint') }}
|
||||||
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group" v-if="!editingRule">
|
<div class="form-group" v-if="!editingRule">
|
||||||
<label>{{ t('security.toolGuard.fields.ruleId') }} <span class="required">*</span></label>
|
<label>{{ t('security.toolGuard.fields.ruleId') }} <span class="required">*</span></label>
|
||||||
@ -156,11 +174,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('security.toolGuard.fields.name') }} <span class="required">*</span></label>
|
<label>{{ t('security.toolGuard.fields.name') }} <span class="required">*</span></label>
|
||||||
<input v-model="ruleForm.name" class="form-input" required />
|
<input v-model="ruleForm.name" class="form-input" :disabled="editingBuiltin" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('security.toolGuard.fields.pattern') }} <span class="required">*</span></label>
|
<label>{{ t('security.toolGuard.fields.pattern') }} <span class="required">*</span></label>
|
||||||
<input v-model="ruleForm.pattern" class="form-input mono" placeholder="regex pattern" required />
|
<input v-model="ruleForm.pattern" class="form-input mono" :disabled="editingBuiltin" placeholder="regex pattern" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('security.toolGuard.fields.severity') }}</label>
|
<label>{{ t('security.toolGuard.fields.severity') }}</label>
|
||||||
@ -174,7 +192,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('security.toolGuard.fields.category') }}</label>
|
<label>{{ t('security.toolGuard.fields.category') }}</label>
|
||||||
<select v-model="ruleForm.category" class="form-input">
|
<select v-model="ruleForm.category" class="form-input" :disabled="editingBuiltin">
|
||||||
<option value="COMMAND_INJECTION">COMMAND_INJECTION</option>
|
<option value="COMMAND_INJECTION">COMMAND_INJECTION</option>
|
||||||
<option value="DATA_EXFILTRATION">DATA_EXFILTRATION</option>
|
<option value="DATA_EXFILTRATION">DATA_EXFILTRATION</option>
|
||||||
<option value="PATH_TRAVERSAL">PATH_TRAVERSAL</option>
|
<option value="PATH_TRAVERSAL">PATH_TRAVERSAL</option>
|
||||||
@ -195,16 +213,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('security.toolGuard.fields.toolName') }}</label>
|
<label>{{ t('security.toolGuard.fields.toolName') }}</label>
|
||||||
<input v-model="ruleForm.toolName" class="form-input" placeholder="execute_shell_command" />
|
<input v-model="ruleForm.toolName" class="form-input" :disabled="editingBuiltin" placeholder="execute_shell_command" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('security.toolGuard.fields.remediation') }}</label>
|
<label>{{ t('security.toolGuard.fields.remediation') }}</label>
|
||||||
<input v-model="ruleForm.remediation" class="form-input" />
|
<input v-model="ruleForm.remediation" class="form-input" :disabled="editingBuiltin" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('security.toolGuard.fields.priority') }}</label>
|
<label>{{ t('security.toolGuard.fields.priority') }}</label>
|
||||||
<input v-model.number="ruleForm.priority" type="number" class="form-input" />
|
<input v-model.number="ruleForm.priority" type="number" class="form-input" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ t('security.toolGuard.fields.excludePattern') }}</label>
|
||||||
|
<input v-model="ruleForm.excludePattern" class="form-input mono" placeholder="regex" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@ -218,7 +240,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mcToast } from '@/composables/useMcToast'
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
import { securityApi } from '@/api'
|
import { securityApi } from '@/api'
|
||||||
@ -302,6 +324,8 @@ function removeDeniedTool(tool: string) {
|
|||||||
const rules = ref<GuardRule[]>([])
|
const rules = ref<GuardRule[]>([])
|
||||||
const showRuleModal = ref(false)
|
const showRuleModal = ref(false)
|
||||||
const editingRule = ref<GuardRule | null>(null)
|
const editingRule = ref<GuardRule | null>(null)
|
||||||
|
const editingBuiltin = computed(() => !!editingRule.value?.builtin)
|
||||||
|
const importFileInput = ref<HTMLInputElement | null>(null)
|
||||||
const ruleForm = reactive({
|
const ruleForm = reactive({
|
||||||
ruleId: '',
|
ruleId: '',
|
||||||
name: '',
|
name: '',
|
||||||
@ -312,6 +336,7 @@ const ruleForm = reactive({
|
|||||||
toolName: '',
|
toolName: '',
|
||||||
remediation: '',
|
remediation: '',
|
||||||
priority: 100,
|
priority: 100,
|
||||||
|
excludePattern: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadRules() {
|
async function loadRules() {
|
||||||
@ -335,6 +360,7 @@ function openCreateRuleModal() {
|
|||||||
toolName: '',
|
toolName: '',
|
||||||
remediation: '',
|
remediation: '',
|
||||||
priority: 100,
|
priority: 100,
|
||||||
|
excludePattern: '',
|
||||||
})
|
})
|
||||||
showRuleModal.value = true
|
showRuleModal.value = true
|
||||||
}
|
}
|
||||||
@ -351,6 +377,7 @@ function openEditRuleModal(rule: GuardRule) {
|
|||||||
toolName: rule.toolName || '',
|
toolName: rule.toolName || '',
|
||||||
remediation: rule.remediation || '',
|
remediation: rule.remediation || '',
|
||||||
priority: rule.priority,
|
priority: rule.priority,
|
||||||
|
excludePattern: (rule as any).excludePattern || '',
|
||||||
})
|
})
|
||||||
showRuleModal.value = true
|
showRuleModal.value = true
|
||||||
}
|
}
|
||||||
@ -406,6 +433,60 @@ async function deleteRule(rule: GuardRule) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Import / Export ====================
|
||||||
|
|
||||||
|
async function exportRules() {
|
||||||
|
try {
|
||||||
|
const res: any = await securityApi.exportRules()
|
||||||
|
const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
const date = new Date().toISOString().slice(0, 10)
|
||||||
|
a.href = url
|
||||||
|
a.download = t('security.toolGuard.exportFileName', { date })
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (e: any) {
|
||||||
|
mcToast.error(e?.msg || e?.message || t('security.toolGuard.importFailed', { msg: '' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerImport() {
|
||||||
|
importFileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onImportFile(ev: Event) {
|
||||||
|
const input = ev.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
const rulesPayload = Array.isArray(parsed) ? parsed : parsed.rules
|
||||||
|
if (!Array.isArray(rulesPayload)) {
|
||||||
|
mcToast.error(t('security.toolGuard.importFailed', { msg: 'invalid JSON: missing rules[]' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res: any = await securityApi.importRules({ rules: rulesPayload })
|
||||||
|
const s = res.data || {}
|
||||||
|
mcToast.success(
|
||||||
|
t('security.toolGuard.importSummary', {
|
||||||
|
inserted: s.inserted ?? 0,
|
||||||
|
updatedBuiltin: s.updatedBuiltin ?? 0,
|
||||||
|
updatedCustom: s.updatedCustom ?? 0,
|
||||||
|
skipped: s.skipped ?? 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await loadRules()
|
||||||
|
} catch (e: any) {
|
||||||
|
mcToast.error(t('security.toolGuard.importFailed', { msg: e?.msg || e?.message || '' }))
|
||||||
|
} finally {
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Init ====================
|
// ==================== Init ====================
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@ -432,4 +513,16 @@ onMounted(async () => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
|
||||||
|
.builtin-hint {
|
||||||
|
background: var(--mc-bg-warning, #fffbeb);
|
||||||
|
color: var(--mc-text-warning, #92400e);
|
||||||
|
border: 1px solid var(--mc-border-warning, #fde68a);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user