mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
|
||||
@ -1158,7 +1166,7 @@ public class ChannelManager {
|
||||
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
|
||||
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
|
||||
feishuMediaUploader, generatedFileScrubber);
|
||||
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager);
|
||||
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
|
||||
case "discord" -> new DiscordChannelAdapter(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.v1.model.P2MessageReceiveV1;
|
||||
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.ChannelMessage;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.ExponentialBackoff;
|
||||
import vip.mate.channel.StreamingChannelAdapter;
|
||||
import vip.mate.channel.media.GeneratedFileScrubber;
|
||||
import vip.mate.channel.media.MediaSource;
|
||||
import vip.mate.channel.media.MediaUploadException;
|
||||
@ -66,7 +69,7 @@ import java.util.concurrent.TimeUnit;
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
||||
public class FeishuChannelAdapter extends AbstractChannelAdapter implements StreamingChannelAdapter {
|
||||
|
||||
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. */
|
||||
private final GeneratedFileScrubber generatedFileScrubber;
|
||||
|
||||
/** CardKit streaming-card manager. Nullable for legacy callers / tests. */
|
||||
private final FeishuStreamingCardManager streamingCardManager;
|
||||
|
||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||
ChannelMessageRouter messageRouter,
|
||||
ObjectMapper objectMapper) {
|
||||
this(channelEntity, messageRouter, objectMapper, null, null);
|
||||
this(channelEntity, messageRouter, objectMapper, null, null, null);
|
||||
}
|
||||
|
||||
public FeishuChannelAdapter(ChannelEntity channelEntity,
|
||||
@ -139,9 +145,19 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
|
||||
ObjectMapper objectMapper,
|
||||
FeishuMediaUploader mediaUploader,
|
||||
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);
|
||||
this.mediaUploader = mediaUploader;
|
||||
this.generatedFileScrubber = generatedFileScrubber;
|
||||
this.streamingCardManager = streamingCardManager;
|
||||
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
|
||||
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;
|
||||
|
||||
// ==================== 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
|
||||
public void sendMessage(String targetId, String content) {
|
||||
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 java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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 ====================
|
||||
|
||||
@Operation(summary = "审计日志")
|
||||
|
||||
@ -66,6 +66,19 @@ public class ToolGuardRuleRegistry implements ApplicationRunner {
|
||||
.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 java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 策略解析器
|
||||
@ -15,44 +14,66 @@ import java.util.Set;
|
||||
* <li>Guardian 只负责发现风险事实</li>
|
||||
* <li>PolicyResolver 负责把事实映射为执行策略</li>
|
||||
* </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
|
||||
@Component
|
||||
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) {
|
||||
// 无 findings → 直接允许(不再按工具类型默认审批)
|
||||
if (findings == null || findings.isEmpty()) {
|
||||
return GuardDecision.ALLOW;
|
||||
}
|
||||
|
||||
GuardSeverity maxSeverity = findings.stream()
|
||||
.map(GuardFinding::severity)
|
||||
.reduce(GuardSeverity.INFO, GuardSeverity::max);
|
||||
GuardDecision aggregate = GuardDecision.ALLOW;
|
||||
for (GuardFinding f : findings) {
|
||||
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;
|
||||
}
|
||||
|
||||
// HIGH / MEDIUM → 需要审批
|
||||
if (maxSeverity.isAtLeast(GuardSeverity.MEDIUM)) {
|
||||
if (sev.isAtLeast(GuardSeverity.MEDIUM)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -61,14 +82,12 @@ public class ToolPolicyResolver {
|
||||
*/
|
||||
public String buildSummary(List<GuardFinding> findings, GuardDecision decision) {
|
||||
if (findings == null || findings.isEmpty()) {
|
||||
// 无 findings 时不应该有 NEEDS_APPROVAL 或 BLOCK
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("检测到 ").append(findings.size()).append(" 项安全风险");
|
||||
|
||||
// 列出最高风险的发现
|
||||
findings.stream()
|
||||
.filter(f -> f.severity() != null && f.severity().isAtLeast(GuardSeverity.MEDIUM))
|
||||
.limit(3)
|
||||
|
||||
@ -2,6 +2,7 @@ package vip.mate.tool.guard.guardian;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.tool.guard.engine.ToolGuardRuleRegistry;
|
||||
import vip.mate.tool.guard.model.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@ -14,8 +15,13 @@ import java.util.regex.Pattern;
|
||||
/**
|
||||
* 凭据泄露守卫
|
||||
* <p>
|
||||
* 检测工具参数中可能包含的敏感凭据信息。
|
||||
* alwaysRun=true,不受 guarded tools 范围限制。
|
||||
* 检测工具参数中可能包含的敏感凭据信息。alwaysRun=true,不受 guarded tools 范围限制。
|
||||
* <p>
|
||||
* 规则优先级:
|
||||
* <ol>
|
||||
* <li>优先读取 DB 中 category=CREDENTIAL_EXPOSURE 的已启用规则(受 UI 开关控制);</li>
|
||||
* <li>DB 无任何凭据规则时回退到内置硬编码列表(保证未初始化部署也能工作)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ -23,31 +29,43 @@ public class CredentialExposureGuardian implements ToolGuardGuardian {
|
||||
|
||||
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",
|
||||
"(password|secret|api[_-]?key|token)\\s*=\\s*['\"]?\\S{8,}",
|
||||
"凭据信息暴露",
|
||||
"检测到可能的密码/密钥/Token 赋值"),
|
||||
"检测到可能的密码/密钥/Token 赋值",
|
||||
GuardDecision.NEEDS_APPROVAL),
|
||||
new CredentialRule("CRED_AWS_KEY",
|
||||
"AKIA[0-9A-Z]{16}",
|
||||
"AWS Access Key 泄露",
|
||||
"检测到 AWS Access Key ID 模式"),
|
||||
"检测到 AWS Access Key ID 模式",
|
||||
GuardDecision.NEEDS_APPROVAL),
|
||||
new CredentialRule("CRED_PRIVATE_KEY",
|
||||
"-----BEGIN\\s+(RSA\\s+)?PRIVATE\\s+KEY-----",
|
||||
"私钥泄露",
|
||||
"检测到 PEM 格式私钥"),
|
||||
"检测到 PEM 格式私钥",
|
||||
GuardDecision.BLOCK),
|
||||
new CredentialRule("CRED_JWT_TOKEN",
|
||||
"eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]+",
|
||||
"JWT Token 泄露",
|
||||
"检测到 JWT Token 格式的字符串"),
|
||||
"检测到 JWT Token 格式的字符串",
|
||||
GuardDecision.NEEDS_APPROVAL),
|
||||
new CredentialRule("CRED_GITHUB_TOKEN",
|
||||
"gh[pousr]_[A-Za-z0-9_]{36,}",
|
||||
"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
|
||||
public boolean supports(ToolInvocationContext context) {
|
||||
return true;
|
||||
@ -69,7 +87,45 @@ public class CredentialExposureGuardian implements ToolGuardGuardian {
|
||||
if (raw == null || raw.isEmpty()) return List.of();
|
||||
|
||||
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,
|
||||
r -> Pattern.compile(r, Pattern.CASE_INSENSITIVE));
|
||||
Matcher matcher = p.matcher(raw);
|
||||
@ -85,13 +141,32 @@ public class CredentialExposureGuardian implements ToolGuardGuardian {
|
||||
context.toolName(),
|
||||
null,
|
||||
rule.pattern,
|
||||
maskCredential(snippet)
|
||||
maskCredential(snippet),
|
||||
rule.decision
|
||||
));
|
||||
}
|
||||
}
|
||||
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) {
|
||||
int start = Math.max(0, matchStart - contextLen / 2);
|
||||
int end = Math.min(input.length(), matchStart + contextLen / 2);
|
||||
|
||||
@ -79,7 +79,8 @@ public class ShellCommandGuardian implements ToolGuardGuardian {
|
||||
context.toolName(),
|
||||
rule.getParamName() != null ? rule.getParamName() : "command",
|
||||
rule.getPattern(),
|
||||
snippet
|
||||
snippet,
|
||||
parseDecision(rule.getDecision())
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -115,6 +116,15 @@ public class ShellCommandGuardian implements ToolGuardGuardian {
|
||||
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) {
|
||||
int start = Math.max(0, matchStart - contextLen / 2);
|
||||
int end = Math.min(input.length(), matchStart + contextLen / 2);
|
||||
|
||||
@ -7,6 +7,10 @@ import java.util.Map;
|
||||
* <p>
|
||||
* 由 Guardian 评估产出,携带完整的威胁上下文信息。
|
||||
* 使用不可变 record,产出后不允许被修改。
|
||||
* <p>
|
||||
* {@code decision} 为可选的"该规则希望的最终裁决"。Guardian 若从 DB 加载规则,
|
||||
* 应把 rule.decision 透传过来;PolicyResolver 在聚合阶段会取所有 findings 中
|
||||
* 最严格的一项作为最终 decision,未设置时回退到 severity → 默认动作。
|
||||
*/
|
||||
public record GuardFinding(
|
||||
String ruleId,
|
||||
@ -19,6 +23,7 @@ public record GuardFinding(
|
||||
String paramName,
|
||||
String matchedPattern,
|
||||
String snippet,
|
||||
GuardDecision decision,
|
||||
Map<String, Object> metadata
|
||||
) {
|
||||
|
||||
@ -26,7 +31,15 @@ public record GuardFinding(
|
||||
String title, String description, String remediation,
|
||||
String toolName, String paramName, String matchedPattern, String snippet) {
|
||||
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("paramName", paramName != null ? paramName : ""),
|
||||
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());
|
||||
}
|
||||
} else if (needsUpdate(existing, rule)) {
|
||||
// 已存在但内容有变化 → 更新
|
||||
// 已存在但内容字段有变化 → 同步代码侧拥有的字段(content fields)。
|
||||
// 严格保留用户侧拥有的策略字段(severity / decision / priority / enabled
|
||||
// / excludePattern)—— 这些一旦用户在 UI 上调整,重启后不应被覆盖。
|
||||
ruleMapper.update(null,
|
||||
new LambdaUpdateWrapper<ToolGuardRuleEntity>()
|
||||
.eq(ToolGuardRuleEntity::getRuleId, rule.getRuleId())
|
||||
.set(ToolGuardRuleEntity::getName, rule.getName())
|
||||
.set(ToolGuardRuleEntity::getDescription, rule.getDescription())
|
||||
.set(ToolGuardRuleEntity::getPattern, rule.getPattern())
|
||||
.set(ToolGuardRuleEntity::getSeverity, rule.getSeverity())
|
||||
.set(ToolGuardRuleEntity::getCategory, rule.getCategory())
|
||||
.set(ToolGuardRuleEntity::getDecision, rule.getDecision())
|
||||
.set(ToolGuardRuleEntity::getToolName, rule.getToolName())
|
||||
.set(ToolGuardRuleEntity::getRemediation, rule.getRemediation())
|
||||
.set(ToolGuardRuleEntity::getPriority, rule.getPriority()));
|
||||
.set(ToolGuardRuleEntity::getRemediation, rule.getRemediation()));
|
||||
updated++;
|
||||
} else {
|
||||
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) {
|
||||
return !Objects.equals(existing.getPattern(), expected.getPattern())
|
||||
|| !Objects.equals(existing.getSeverity(), expected.getSeverity())
|
||||
return !Objects.equals(existing.getName(), expected.getName())
|
||||
|| !Objects.equals(existing.getDescription(), expected.getDescription())
|
||||
|| !Objects.equals(existing.getPattern(), expected.getPattern())
|
||||
|| !Objects.equals(existing.getCategory(), expected.getCategory())
|
||||
|| !Objects.equals(existing.getDecision(), expected.getDecision())
|
||||
|| !Objects.equals(existing.getToolName(), expected.getToolName())
|
||||
|| !Objects.equals(existing.getPriority(), expected.getPriority())
|
||||
|| !Objects.equals(existing.getName(), expected.getName())
|
||||
|| !Objects.equals(existing.getRemediation(), expected.getRemediation());
|
||||
}
|
||||
|
||||
@ -330,6 +334,16 @@ public class ToolGuardRuleSeedService implements ApplicationRunner {
|
||||
GuardSeverity.HIGH, GuardCategory.CREDENTIAL_EXPOSURE, "BLOCK",
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,11 @@ import vip.mate.tool.guard.engine.ToolGuardRuleRegistry;
|
||||
import vip.mate.tool.guard.model.ToolGuardRuleEntity;
|
||||
import vip.mate.tool.guard.repository.ToolGuardRuleMapper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工具安全规则 CRUD 服务
|
||||
*/
|
||||
@ -94,6 +99,11 @@ public class ToolGuardRuleService {
|
||||
if (existing == null) {
|
||||
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) {
|
||||
requireNonBlank(update.getName(), "Rule name");
|
||||
@ -153,6 +163,130 @@ public class ToolGuardRuleService {
|
||||
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 因历史脏数据为空或无法走
|
||||
* /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_PRIVATE_KEY.name=\u79c1\u94a5\u6cc4\u9732
|
||||
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) ---
|
||||
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_PRIVATE_KEY.name=Private key leak
|
||||
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 ---
|
||||
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.feishu.FeishuMediaUploader.class),
|
||||
mock(vip.mate.channel.media.GeneratedFileScrubber.class),
|
||||
mock(vip.mate.channel.feishu.FeishuStreamingCardManager.class),
|
||||
election);
|
||||
adapter = new TrackingAdapter();
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ class FeishuMediaWiringIT {
|
||||
@Autowired private FeishuMediaUploader mediaUploader;
|
||||
@Autowired private FeishuSizePolicy sizePolicy;
|
||||
@Autowired private GeneratedFileScrubber scrubber;
|
||||
@Autowired private FeishuStreamingCardManager streamingCardManager;
|
||||
@Autowired private List<MediaUploader> uploaderBeans;
|
||||
@Autowired private List<MediaSizePolicy> policyBeans;
|
||||
|
||||
@ -43,6 +44,7 @@ class FeishuMediaWiringIT {
|
||||
assertNotNull(mediaUploader);
|
||||
assertNotNull(sizePolicy);
|
||||
assertNotNull(scrubber);
|
||||
assertNotNull(streamingCardManager);
|
||||
}
|
||||
|
||||
@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) =>
|
||||
http.put(`/security/guard/rules/${ruleId}/toggle?enabled=${enabled}`),
|
||||
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 }),
|
||||
getAuditStats: () => http.get('/security/audit/stats'),
|
||||
listApprovals: (params?: any) => http.get('/security/approvals', { params }),
|
||||
|
||||
@ -1457,6 +1457,13 @@ export default {
|
||||
ruleIdDuplicate: 'Rule ID already exists',
|
||||
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: {
|
||||
title: 'File Guard',
|
||||
|
||||
@ -1349,6 +1349,13 @@ export default {
|
||||
ruleIdDuplicate: '规则 ID 已存在',
|
||||
saveFailed: '保存规则失败',
|
||||
},
|
||||
exportBtn: '导出 JSON',
|
||||
importBtn: '导入 JSON',
|
||||
exportFileName: 'mateclaw-guard-rules-{date}.json',
|
||||
importPromptHint: '上传通过"导出"产生的 JSON 文件;内置规则只会同步策略字段。',
|
||||
importSummary: '导入完成:新增 {inserted},更新内置 {updatedBuiltin},更新自定义 {updatedCustom},跳过 {skipped}',
|
||||
importFailed: '导入失败:{msg}',
|
||||
builtinLockedHint: '内置规则的名称、模式与分类由系统管理,此处仅可调整严重度、决策、优先级、白名单与启用状态。',
|
||||
},
|
||||
fileGuard: {
|
||||
title: '文件防护',
|
||||
|
||||
@ -5,9 +5,24 @@
|
||||
<h2 class="section-title">{{ t('security.toolGuard.title') }}</h2>
|
||||
<p class="section-desc">{{ t('security.toolGuard.desc') }}</p>
|
||||
</div>
|
||||
<button class="btn-primary" @click="openCreateRuleModal">
|
||||
{{ t('security.toolGuard.addRule') }}
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button class="btn-secondary" @click="exportRules">
|
||||
{{ 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>
|
||||
|
||||
<!-- Global Config -->
|
||||
@ -144,6 +159,9 @@
|
||||
<button class="modal-close" @click="showRuleModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="editingBuiltin" class="builtin-hint">
|
||||
{{ t('security.toolGuard.builtinLockedHint') }}
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group" v-if="!editingRule">
|
||||
<label>{{ t('security.toolGuard.fields.ruleId') }} <span class="required">*</span></label>
|
||||
@ -156,11 +174,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<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 class="form-group">
|
||||
<label>{{ t('security.toolGuard.fields.severity') }}</label>
|
||||
@ -174,7 +192,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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="DATA_EXFILTRATION">DATA_EXFILTRATION</option>
|
||||
<option value="PATH_TRAVERSAL">PATH_TRAVERSAL</option>
|
||||
@ -195,16 +213,20 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<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 class="form-group">
|
||||
<label>{{ t('security.toolGuard.fields.priority') }}</label>
|
||||
<input v-model.number="ruleForm.priority" type="number" class="form-input" />
|
||||
</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 class="modal-footer">
|
||||
@ -218,7 +240,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import { securityApi } from '@/api'
|
||||
@ -302,6 +324,8 @@ function removeDeniedTool(tool: string) {
|
||||
const rules = ref<GuardRule[]>([])
|
||||
const showRuleModal = ref(false)
|
||||
const editingRule = ref<GuardRule | null>(null)
|
||||
const editingBuiltin = computed(() => !!editingRule.value?.builtin)
|
||||
const importFileInput = ref<HTMLInputElement | null>(null)
|
||||
const ruleForm = reactive({
|
||||
ruleId: '',
|
||||
name: '',
|
||||
@ -312,6 +336,7 @@ const ruleForm = reactive({
|
||||
toolName: '',
|
||||
remediation: '',
|
||||
priority: 100,
|
||||
excludePattern: '',
|
||||
})
|
||||
|
||||
async function loadRules() {
|
||||
@ -335,6 +360,7 @@ function openCreateRuleModal() {
|
||||
toolName: '',
|
||||
remediation: '',
|
||||
priority: 100,
|
||||
excludePattern: '',
|
||||
})
|
||||
showRuleModal.value = true
|
||||
}
|
||||
@ -351,6 +377,7 @@ function openEditRuleModal(rule: GuardRule) {
|
||||
toolName: rule.toolName || '',
|
||||
remediation: rule.remediation || '',
|
||||
priority: rule.priority,
|
||||
excludePattern: (rule as any).excludePattern || '',
|
||||
})
|
||||
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 ====================
|
||||
|
||||
onMounted(async () => {
|
||||
@ -432,4 +513,16 @@ onMounted(async () => {
|
||||
text-overflow: ellipsis;
|
||||
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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user