feat(approval): classify auto-approve misses and persist the outcome on guard audit rows

This commit is contained in:
matevip 2026-07-23 11:44:21 +08:00
parent a4ee953d31
commit 1bb81234d0
15 changed files with 219 additions and 353 deletions

View File

@ -1035,7 +1035,10 @@ public class ToolExecutionExecutor {
.withWorkspaceBasePath(origin != null ? origin.workspaceBasePath() : null);
if (toolGuardService != null) {
GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx);
// Defer the NEEDS_APPROVAL audit row: it is written below, once, after
// the auto-grant decision, so it carries the resolution outcome
// (AUTO_GRANT / SEVERITY_CEILING / NO_GRANT / ) and the pendingId.
GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx, true);
if (evaluation.shouldBlock()) {
log.warn("[ToolExecutor] Tool call BLOCKED: tool={}, summary={}", toolName, evaluation.summary());
@ -1049,6 +1052,7 @@ public class ToolExecutionExecutor {
// HARD_BLOCK short-circuits to a blocked decision (no approval banner).
// APPROVED skips createPending() and lets the tool run as normal.
// REQUIRES_HUMAN falls through to the existing manual approval path.
String autoOutcome = null;
if (autoGrantWired) {
AutoApproveResult auto = approvalGrantResolver.tryAutoApprove(guardCtx, evaluation);
if (auto.isHardBlocked()) {
@ -1057,19 +1061,24 @@ public class ToolExecutionExecutor {
+ "Please use a safer alternative.";
log.warn("[ToolExecutor] Auto-grant HARD_BLOCK: tool={}, reason={}", toolName, auto.reason());
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
toolGuardService.recordApprovalAudit(guardCtx, evaluation, null, "HARD_BLOCK");
return GuardDecision.blocked(msg);
}
if (auto.isApproved()) {
log.info("[ToolExecutor] Auto-grant APPROVED: tool={}, grantId={}", toolName, auto.grantId());
toolGuardService.recordApprovalAudit(guardCtx, evaluation, null, "AUTO_GRANT");
return GuardDecision.allowed();
}
// requiresHuman fall through to legacy human-approval path below.
// requiresHuman fall through to legacy human-approval path below,
// carrying the denial reason for the audit row.
autoOutcome = auto.reason();
}
// No human can resolve an approval in a non-interactive (scheduled-job)
// run, so a pending request would hang the turn until it times out with
// no answer. Deny immediately with an actionable message instead.
if (origin != null && origin.cronOrigin()) {
toolGuardService.recordApprovalAudit(guardCtx, evaluation, null, autoOutcome);
return denyNonInteractiveApproval(toolCall, toolName, events);
}
@ -1079,7 +1088,9 @@ public class ToolExecutionExecutor {
conversationId, agentId, requesterId, approvalService, streamTracker,
events, remaining);
// Extract pendingId from response (format: "[APPROVAL_PENDING] tool=xxx awaiting user decision")
return GuardDecision.needsApproval(approvalResponse, extractPendingId(approvalResponse));
String pendingId = extractPendingId(approvalResponse);
toolGuardService.recordApprovalAudit(guardCtx, evaluation, pendingId, autoOutcome);
return GuardDecision.needsApproval(approvalResponse, pendingId);
}
} else if (toolGuard != null) {
ToolGuardResult guardResult = toolGuard.check(toolName, arguments);

View File

@ -49,6 +49,21 @@ public interface ApprovalGrantMapper extends BaseMapper<ApprovalGrant> {
@Param("candidateRuleIds") List<String> candidateRuleIds,
@Param("evalSeverity") String evalSeverity);
/**
* Diagnostic twin of {@link #findFirstMatching}: identical matching except the
* severity-ceiling comparison is dropped. Called only when {@code findFirstMatching}
* returned no row, to distinguish "a grant exists but its ceiling is below this
* invocation's severity" (SEVERITY_CEILING) from "no grant matches at all" (NO_GRANT).
*/
ApprovalGrant findFirstMatchingIgnoringSeverity(
@Param("workspaceId") Long workspaceId,
@Param("userId") String userId,
@Param("agentId") String agentId,
@Param("conversationId") String conversationId,
@Param("workspaceScopeId") String workspaceScopeId,
@Param("toolName") String toolName,
@Param("candidateRuleIds") List<String> candidateRuleIds);
/**
* Soft-revokes every active {@code UNTIL_CONVERSATION_END} grant attached to the given
* conversation. Called by {@code ConversationLifecycleListener} on

View File

@ -114,6 +114,21 @@ public class ApprovalGrantResolver {
evalSeverity);
if (matched == null) {
// Miss-path diagnosis: re-run the same match without the severity
// ceiling. A hit here means a grant exists but its ceiling is below
// this invocation's severity the single most common misconfiguration
// (form default LOW vs HIGH findings). Only executed on the miss path,
// so the hot path stays one query.
ApprovalGrant ceilingBlocked = grantMapper.findFirstMatchingIgnoringSeverity(
ctx.workspaceId(),
ctx.userId(), ctx.agentId(), ctx.conversationId(),
workspaceScopeId,
ctx.toolName(),
candidateRuleIds);
if (ceilingBlocked != null) {
return AutoApproveResult.requiresHuman(
"SEVERITY_CEILING:" + ceilingBlocked.getMaxSeverity() + "<" + evalSeverity);
}
return AutoApproveResult.requiresHuman("NO_GRANT");
}

View File

@ -21,7 +21,7 @@ import java.util.Optional;
final class ChannelMagicCommand {
/** Platform-level command kinds, dispatched by {@link ChannelMessageRouter}. */
enum Type { CLEAR, NEW, HELP, STATUS, STOP, MODEL }
enum Type { CLEAR, NEW, HELP, STATUS, STOP }
/** A recognized command plus its raw (possibly empty) argument string. */
record Parsed(Type type, String args) {
@ -85,7 +85,6 @@ final class ChannelMagicCommand {
/new 开启新会话别名新会话
/stop 停止当前进行中的任务别名停止
/status 查看当前会话状态别名状态
/model 查看可用模型/model <名称> 切换本会话模型/model reset 恢复默认
/help 显示本帮助别名帮助""";
}
@ -102,10 +101,6 @@ final class ChannelMagicCommand {
"status", "状态");
register(aliases, Type.STOP,
"stop", "停止");
// Slash-only: "model" / "模型" are common standalone words in normal
// prompts ("模型是什么?"), so the bare form must never be a command.
registerSlashOnly(aliases, Type.MODEL,
"model", "模型");
return aliases;
}
@ -116,13 +111,6 @@ final class ChannelMagicCommand {
}
}
/** Register only the "/"-prefixed form — for aliases whose bare word is ordinary prose. */
private static void registerSlashOnly(Map<String, Type> aliases, Type type, String... names) {
for (String name : names) {
aliases.put("/" + name, type);
}
}
private static int indexOfWhitespace(String text) {
for (int i = 0; i < text.length(); i++) {
if (Character.isWhitespace(text.charAt(i))) {

View File

@ -18,12 +18,9 @@ import vip.mate.channel.service.ChannelService;
import vip.mate.channel.web.AgentStreamAccumulator;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.tts.TtsService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.workspace.conversation.model.MessageEntity;
@ -37,7 +34,6 @@ import java.nio.file.Paths;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.*;
@ -83,13 +79,6 @@ public class ChannelMessageRouter {
@Autowired(required = false)
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
/** Field-injected for the same reason as {@link #events}: backs the
* /model magic command (list + switch). Optional so tests that build
* the router directly still work; when unset the command degrades to
* a "service unavailable" reply instead of failing message intake. */
@Autowired(required = false)
private ModelConfigService modelConfigService;
/** Field-injected so the IM sync path can scrub hallucinated
* {@code /api/v1/files/generated/{id}} URLs (LLM wrote a UUID-shaped
* link without ever calling a render tool). The graph's FinalAnswerNode
@ -988,7 +977,6 @@ public class ChannelMessageRouter {
}
case HELP -> ChannelMagicCommand.helpText();
case STATUS -> buildStatusReply(channelEntity, conversationId);
case MODEL -> handleModelCommand(channelEntity, conversationId, command.args());
};
if (replyTarget != null && reply != null) {
// renderAndSend (not sendMessage) so adapters that pre-post a
@ -1009,7 +997,6 @@ public class ChannelMessageRouter {
StringBuilder sb = new StringBuilder("📊 会话状态\n");
sb.append("- 会话: ").append(conversationId).append('\n');
Long agentId = channelEntity != null ? channelEntity.getAgentId() : null;
String[] pinned = findPinnedModel(conversationId);
if (agentId == null) {
sb.append("- 智能体: 未绑定\n");
} else {
@ -1017,13 +1004,7 @@ public class ChannelMessageRouter {
AgentEntity agent = agentService.getAgent(agentId);
if (agent != null) {
sb.append("- 智能体: ").append(agent.getName()).append('\n');
// Conversation-pinned model wins over the agent default
// mirrors the resolution order in AgentService, so /status
// never contradicts what /model just switched to.
if (pinned != null) {
sb.append("- 模型: ").append(pinned[0]).append(':').append(pinned[1])
.append("(会话指定)\n");
} else if (agent.getModelName() != null && !agent.getModelName().isBlank()) {
if (agent.getModelName() != null && !agent.getModelName().isBlank()) {
sb.append("- 模型: ").append(agent.getModelName()).append('\n');
}
} else {
@ -1044,147 +1025,6 @@ public class ChannelMessageRouter {
return sb.toString();
}
/**
* Handle the /model command: list enabled chat models, pin one on this
* conversation, or reset to the agent default. Listing and resetting work
* without a bound agent; switching requires one because the pinned pair
* only takes effect when the agent graph is built.
*/
private String handleModelCommand(ChannelEntity channelEntity, String conversationId, String args) {
if (modelConfigService == null) {
return "⚠️ 模型管理服务不可用,请稍后再试。";
}
String arg = args == null ? "" : args.trim();
if ("reset".equalsIgnoreCase(arg) || "恢复默认".equals(arg)) {
conversationService.clearConversationModel(conversationId);
return "✅ 已恢复默认模型(跟随智能体配置),下一条消息生效。";
}
List<ModelConfigEntity> models;
try {
models = modelConfigService.listEnabledModels();
} catch (Exception e) {
log.warn("Failed to list models for /model on {}: {}", conversationId, e.getMessage());
return "⚠️ 查询模型列表失败,请稍后再试。";
}
if (arg.isEmpty() || "list".equalsIgnoreCase(arg)) {
return buildModelListReply(models, conversationId);
}
return switchConversationModel(channelEntity, conversationId, arg, models);
}
/** Max rows shown by /model list a full catalog can exceed 180 rows,
* which segments into several IM bubbles and buries the usage hint. */
private static final int MODEL_LIST_MAX_ROWS = 20;
private String buildModelListReply(List<ModelConfigEntity> models, String conversationId) {
if (models.isEmpty()) {
return "当前没有已启用的对话模型,请先在控制台配置。";
}
String[] pinned = findPinnedModel(conversationId);
StringBuilder sb = new StringBuilder("🧠 可用模型(/model <名称> 切换,/model reset 恢复默认):\n");
int shown = 0;
for (ModelConfigEntity m : models) {
if (shown >= MODEL_LIST_MAX_ROWS) {
break;
}
sb.append("- ").append(m.getProvider()).append(':').append(m.getModelName());
if (pinned != null && pinned[0].equalsIgnoreCase(String.valueOf(m.getProvider()))
&& pinned[1].equalsIgnoreCase(String.valueOf(m.getModelName()))) {
sb.append(" ✅ 当前");
}
sb.append('\n');
shown++;
}
if (models.size() > MODEL_LIST_MAX_ROWS) {
sb.append("…共 ").append(models.size())
.append(" 个已启用模型,仅展示前 ").append(MODEL_LIST_MAX_ROWS)
.append(" 个;发送 /model <关键词> 搜索其余模型。\n");
}
sb.append(pinned == null
? "当前:跟随智能体默认模型"
: "当前会话已指定:" + pinned[0] + ":" + pinned[1]);
return sb.toString();
}
private String switchConversationModel(ChannelEntity channelEntity, String conversationId,
String arg, List<ModelConfigEntity> models) {
if (channelEntity == null || channelEntity.getAgentId() == null) {
return "⚠️ 当前渠道未绑定智能体,请先在控制台绑定后再切换模型。";
}
String wantedProvider = null;
String wantedName = arg;
int colon = arg.indexOf(':');
if (colon > 0 && colon < arg.length() - 1) {
wantedProvider = arg.substring(0, colon).trim();
wantedName = arg.substring(colon + 1).trim();
}
final String fProvider = wantedProvider;
final String fName = wantedName;
List<ModelConfigEntity> matches = models.stream()
.filter(m -> fName.equalsIgnoreCase(m.getModelName()))
.filter(m -> fProvider == null || fProvider.equalsIgnoreCase(m.getProvider()))
.toList();
if (matches.isEmpty()) {
// No exact hit treat the arg as a search keyword so users can
// discover models the capped /model list didn't show.
String keyword = fName.toLowerCase(Locale.ROOT);
List<ModelConfigEntity> fuzzy = models.stream()
.filter(m -> String.valueOf(m.getModelName()).toLowerCase(Locale.ROOT).contains(keyword)
|| String.valueOf(m.getProvider()).toLowerCase(Locale.ROOT).contains(keyword))
.limit(MODEL_LIST_MAX_ROWS)
.toList();
if (fuzzy.isEmpty()) {
return "⚠️ 未找到已启用的模型「" + arg + "」,发送 /model 查看可用列表。";
}
StringBuilder sb = new StringBuilder("未找到精确匹配「").append(arg)
.append("」,相近的可用模型:\n");
for (ModelConfigEntity m : fuzzy) {
sb.append("- /model ").append(m.getProvider()).append(':')
.append(m.getModelName()).append('\n');
}
return sb.toString().stripTrailing();
}
if (matches.size() > 1) {
StringBuilder sb = new StringBuilder("⚠️ 模型「").append(fName)
.append("」在多个 provider 下存在,请带上前缀再试:\n");
for (ModelConfigEntity m : matches) {
sb.append("- /model ").append(m.getProvider()).append(':').append(m.getModelName()).append('\n');
}
return sb.toString().stripTrailing();
}
ModelConfigEntity target = matches.get(0);
try {
// The magic-command layer runs before processMessage's
// get-or-create, so a /model sent as the very first message must
// create the conversation row itself updateConversationModel
// silently no-ops on a missing row.
conversationService.getOrCreateSharedConversation(
conversationId, channelEntity.getAgentId(), channelEntity.getWorkspaceId());
conversationService.updateConversationModel(
conversationId, target.getProvider(), target.getModelName());
} catch (Exception e) {
log.warn("Failed to pin model {} on {}: {}", arg, conversationId, e.getMessage());
return "⚠️ 切换失败,请稍后再试。";
}
return "✅ 本会话模型已切换为 " + target.getProvider() + ":" + target.getModelName()
+ ",下一条消息生效。发送 /model reset 可恢复默认。";
}
/** Conversation-pinned (provider, model) pair, or null when unpinned/unavailable. */
private String[] findPinnedModel(String conversationId) {
try {
ConversationEntity conv = conversationService.findByConversationId(conversationId);
if (conv != null
&& conv.getModelProvider() != null && !conv.getModelProvider().isBlank()
&& conv.getModelName() != null && !conv.getModelName().isBlank()) {
return new String[]{conv.getModelProvider(), conv.getModelName()};
}
} catch (Exception e) {
log.debug("Failed to load pinned model for {}: {}", conversationId, e.getMessage());
}
return null;
}
private void cancelPending(String conversationId) {
PendingMessage pending;
synchronized (pendingMessages) {

View File

@ -27,6 +27,14 @@ public class ToolGuardAuditLogEntity {
private String pendingId;
private String replayPayloadHash;
/**
* Auto-approve resolution outcome for NEEDS_APPROVAL invocations:
* AUTO_GRANT / HARD_BLOCK / FORCE_HUMAN:&lt;pattern&gt; / SEVERITY_CRITICAL /
* SEVERITY_CEILING:&lt;ceiling&gt;&lt;&lt;actual&gt; / UNKNOWN_WORKSPACE / NO_GRANT.
* NULL when the invocation never reached the auto-grant decision layer.
*/
private String autoApproveOutcome;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -43,6 +43,18 @@ public class ToolGuardAuditService {
*/
@Async
public void record(ToolInvocationContext context, GuardEvaluation evaluation, String pendingId) {
record(context, evaluation, pendingId, null);
}
/**
* 记录审计日志并附带自动批准决策结果
* <p>
* {@code autoApproveOutcome} NEEDS_APPROVAL 调用经过 auto-grant 决策层后的
* 结果码AUTO_GRANT / SEVERITY_CEILING: / NO_GRANT 未经过该层时为 null
*/
@Async
public void record(ToolInvocationContext context, GuardEvaluation evaluation,
String pendingId, String autoApproveOutcome) {
try {
// 审计开关检查
if (!configService.isAuditEnabled()) {
@ -67,6 +79,7 @@ public class ToolGuardAuditService {
entity.setDecision(evaluation.decision().name());
entity.setMaxSeverity(evaluation.maxSeverity() != null ? evaluation.maxSeverity().name() : null);
entity.setPendingId(pendingId);
entity.setAutoApproveOutcome(autoApproveOutcome);
if (evaluation.hasFindings()) {
entity.setFindingsJson(serializeFindings(evaluation));

View File

@ -32,6 +32,18 @@ public class ToolGuardService {
* 通过后再委托 ToolGuardEngine Guardian 规则评估
*/
public GuardEvaluation evaluate(ToolInvocationContext context) {
return evaluate(context, false);
}
/**
* 评估工具调用可选延迟 NEEDS_APPROVAL 行的审计记录
* <p>
* {@code deferApprovalAudit=true} NEEDS_APPROVAL 结果不在此处落审计
* 调用方在 auto-grant 决策完成后通过
* {@link #recordApprovalAudit(ToolInvocationContext, GuardEvaluation, String, String)}
* 补记一行行内带上决策结果码与 pendingIdALLOW / BLOCK 行为不变
*/
public GuardEvaluation evaluate(ToolInvocationContext context, boolean deferApprovalAudit) {
// 全局开关guard 禁用时直接放行
if (!configService.isEnabled()) {
return GuardEvaluation.allow(context.toolName());
@ -47,16 +59,35 @@ public class ToolGuardService {
GuardEvaluation evaluation = engine.evaluate(context);
// 异步审计记录
try {
auditService.record(context, evaluation, null);
} catch (Exception e) {
log.warn("[ToolGuardService] Failed to record audit: {}", e.getMessage());
// 异步审计记录NEEDS_APPROVAL 且调用方要求延迟时跳过由调用方补记
if (!(deferApprovalAudit && evaluation.shouldRequireApproval())) {
try {
auditService.record(context, evaluation, null);
} catch (Exception e) {
log.warn("[ToolGuardService] Failed to record audit: {}", e.getMessage());
}
}
return evaluation;
}
/**
* 补记被 {@code evaluate(context, true)} 延迟的 NEEDS_APPROVAL 审计行
*
* @param autoApproveOutcome auto-grant 决策结果码AUTO_GRANT / HARD_BLOCK /
* FORCE_HUMAN:xxx / SEVERITY_CRITICAL / SEVERITY_CEILING:xxx /
* UNKNOWN_WORKSPACE / NO_GRANT未接线时为 null
* @param pendingId 人审路径创建的待批 id无则为 null
*/
public void recordApprovalAudit(ToolInvocationContext context, GuardEvaluation evaluation,
String pendingId, String autoApproveOutcome) {
try {
auditService.record(context, evaluation, pendingId, autoApproveOutcome);
} catch (Exception e) {
log.warn("[ToolGuardService] Failed to record approval audit: {}", e.getMessage());
}
}
/**
* 便捷评估方法
*/

View File

@ -757,24 +757,6 @@ public class ConversationService {
conversationMapper.updateById(conv);
}
/**
* Clear a conversation's pinned model so it falls back to the agent /
* global default. Counterpart of {@link #updateConversationModel}, which
* deliberately treats blank input as "no override supplied" resetting
* therefore needs its own explicit entry point. The null-write goes
* through an update wrapper because {@code updateById} skips null fields.
*/
@Transactional
public void clearConversationModel(String conversationId) {
if (conversationId == null || conversationId.isBlank()) {
return;
}
conversationMapper.update(null, new LambdaUpdateWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId)
.set(ConversationEntity::getModelProvider, null)
.set(ConversationEntity::getModelName, null));
}
/**
* Persist an assistant placeholder marker only when the last message is a
* user turn (i.e., the assistant never got to reply). Used by the admin

View File

@ -0,0 +1,7 @@
-- V170: Tool-guard audit rows carry the auto-approve resolution outcome
-- (H2 dialect). NULL means the invocation never went through the auto-grant
-- decision layer (decision was ALLOW/BLOCK, or the row predates this column).
-- Values: AUTO_GRANT / HARD_BLOCK / FORCE_HUMAN:<pattern> / SEVERITY_CRITICAL /
-- SEVERITY_CEILING:<ceiling><<actual> / UNKNOWN_WORKSPACE / NO_GRANT.
ALTER TABLE mate_tool_guard_audit_log ADD COLUMN IF NOT EXISTS auto_approve_outcome VARCHAR(64) NULL;

View File

@ -0,0 +1,4 @@
-- See the H2 file for context. KingbaseES (PostgreSQL-compatible) supports
-- ADD COLUMN IF NOT EXISTS natively.
ALTER TABLE mate_tool_guard_audit_log ADD COLUMN IF NOT EXISTS auto_approve_outcome VARCHAR(64) NULL;

View File

@ -0,0 +1,16 @@
-- See the H2 file for context. MySQL 8.0 doesn't support
-- `ADD COLUMN IF NOT EXISTS`, so the existence check goes through
-- INFORMATION_SCHEMA + a prepared statement.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_tool_guard_audit_log'
AND COLUMN_NAME = 'auto_approve_outcome'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE mate_tool_guard_audit_log ADD COLUMN auto_approve_outcome VARCHAR(64) NULL',
'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -12,6 +12,9 @@
WORKSPACE-scope matches against {workspaceScopeId} — pre-converted to a string on the
Java side (`String.valueOf(workspaceId)`), so the SQL itself avoids CAST(... AS VARCHAR/CHAR).
NOTE: findFirstMatchingIgnoringSeverity below is this query minus the severity
comparison — edits to the shared clauses must be applied to BOTH.
-->
<select id="findFirstMatching"
resultType="vip.mate.approval.grant.entity.ApprovalGrant">
@ -63,6 +66,51 @@
LIMIT 1
</select>
<!--
Diagnostic twin of findFirstMatching used ONLY on the miss path: identical
WHERE clauses and ordering EXCEPT the severity-ceiling comparison is dropped.
A row here while findFirstMatching returned none means a grant exists but its
max_severity ceiling is below the evaluation's severity (SEVERITY_CEILING);
no row means genuinely NO_GRANT. Keep the two <select> bodies in sync — any
drift in the shared clauses misclassifies the denial reason
(locked by ApprovalGrantResolverTest).
-->
<select id="findFirstMatchingIgnoringSeverity"
resultType="vip.mate.approval.grant.entity.ApprovalGrant">
SELECT *
FROM mate_approval_grant
WHERE workspace_id = #{workspaceId}
AND revoked = 0
AND deleted = 0
AND (expire_at IS NULL OR expire_at &gt; CURRENT_TIMESTAMP)
AND (tool_name IS NULL OR tool_name = #{toolName})
AND (
rule_id IS NULL
<if test="candidateRuleIds != null and !candidateRuleIds.isEmpty()">
OR rule_id IN
<foreach collection="candidateRuleIds" item="rid" open="(" close=")" separator=",">
#{rid}
</foreach>
</if>
)
AND (
(scope_type = 'CONVERSATION' AND scope_id = #{conversationId})
OR (scope_type = 'AGENT' AND scope_id = #{agentId})
OR (scope_type = 'USER' AND scope_id = #{userId})
OR (scope_type = 'WORKSPACE' AND scope_id = #{workspaceScopeId})
)
ORDER BY
CASE scope_type
WHEN 'CONVERSATION' THEN 1
WHEN 'AGENT' THEN 2
WHEN 'USER' THEN 3
WHEN 'WORKSPACE' THEN 4
END,
CASE WHEN rule_id IS NOT NULL THEN 0 ELSE 1 END,
CASE WHEN tool_name IS NOT NULL THEN 0 ELSE 1 END
LIMIT 1
</select>
<!--
Soft-revokes every active UNTIL_CONVERSATION_END grant attached to the conversation.
Called from ConversationLifecycleListener on ConversationDeletedEvent.

View File

@ -202,9 +202,48 @@ class ApprovalGrantResolverTest {
assertThat(r.isRequiresHuman()).isTrue();
assertThat(r.reason()).isEqualTo("NO_GRANT");
// Miss path runs the severity-free diagnostic exactly once before concluding NO_GRANT.
verify(grantMapper).findFirstMatchingIgnoringSeverity(
anyLong(), any(), any(), any(), any(), any(), anyList());
verify(resolutionMapper, never()).insert(any(ApprovalResolutionLog.class));
}
@Test
void ceiling_blocked_grant_classifies_as_severity_ceiling() {
ToolInvocationContext ctx = ctxWithArgs("touch /tmp/x");
when(grantMapper.findFirstMatching(
anyLong(), any(), any(), any(), any(), any(), anyList(), any()))
.thenReturn(null);
ApprovalGrant lowCeiling = new ApprovalGrant();
lowCeiling.setId(7L);
lowCeiling.setMaxSeverity("LOW");
when(grantMapper.findFirstMatchingIgnoringSeverity(
anyLong(), any(), any(), any(), any(), any(), anyList()))
.thenReturn(lowCeiling);
var r = resolver.tryAutoApprove(ctx, evaluationWith(GuardSeverity.HIGH, "shell.exec"));
assertThat(r.isRequiresHuman()).isTrue();
assertThat(r.reason()).isEqualTo("SEVERITY_CEILING:LOW<HIGH");
verify(resolutionMapper, never()).insert(any(ApprovalResolutionLog.class));
}
@Test
void diagnostic_query_is_skipped_when_primary_query_matches() {
ToolInvocationContext ctx = ctxWithArgs("touch /tmp/x");
ApprovalGrant grant = new ApprovalGrant();
grant.setId(1L);
grant.setMaxSeverity("HIGH");
when(grantMapper.findFirstMatching(
anyLong(), any(), any(), any(), any(), any(), anyList(), any()))
.thenReturn(grant);
resolver.tryAutoApprove(ctx, evaluationWith(GuardSeverity.MEDIUM, "shell.exec"));
verify(grantMapper, never()).findFirstMatchingIgnoringSeverity(
anyLong(), any(), any(), any(), any(), any(), anyList());
}
@Test
void approved_path_emits_correct_audit_log_decision_source() {
ToolInvocationContext ctx = ctxWithArgs("touch /tmp/x");

View File

@ -11,16 +11,11 @@ import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.service.ChannelService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.tts.TtsService;
import vip.mate.workspace.conversation.ConversationService;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ -58,21 +53,6 @@ class ChannelMagicCommandTest {
assertParsed("stop", ChannelMagicCommand.Type.STOP);
}
@Test
@DisplayName("model command is slash-only: bare word stays ordinary prose")
void modelCommandIsSlashOnly() {
assertParsed("/model", ChannelMagicCommand.Type.MODEL);
assertParsed("/模型", ChannelMagicCommand.Type.MODEL);
Optional<ChannelMagicCommand.Parsed> withArgs = ChannelMagicCommand.parse("/model qwen-max");
assertTrue(withArgs.isPresent());
assertEquals(ChannelMagicCommand.Type.MODEL, withArgs.get().type());
assertEquals("qwen-max", withArgs.get().args());
// "model"/"模型" are common standalone words never commands bare.
assertNotParsed("model");
assertNotParsed("模型");
assertNotParsed("模型是什么");
}
@Test
@DisplayName("bare aliases with trailing text are ordinary prompts, not commands")
void bareAliasesWithRemainderDoNotMatch() {
@ -100,7 +80,7 @@ class ChannelMagicCommandTest {
@DisplayName("help text lists every registered command")
void helpTextListsAllCommands() {
String help = ChannelMagicCommand.helpText();
for (String name : new String[]{"/clear", "/new", "/stop", "/status", "/model", "/help"}) {
for (String name : new String[]{"/clear", "/new", "/stop", "/status", "/help"}) {
assertTrue(help.contains(name), "help text missing " + name);
}
}
@ -202,133 +182,8 @@ class ChannelMagicCommandTest {
f.verifyAgentNeverCalled();
}
@Test
@DisplayName("model command lists enabled models with pin/reset usage")
void modelCommandListsModels() throws Exception {
Fixture f = new Fixture();
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max"), model("anthropic", "claude-sonnet-5")));
f.process("/model");
verify(f.adapter).renderAndSend(eq("reply-1"), argThat(text ->
text.contains("qwen-max") && text.contains("claude-sonnet-5")
&& text.contains("/model reset")));
f.verifyAgentNeverCalled();
}
@Test
@DisplayName("model switch pins the matched model on the conversation (creating it first)")
void modelSwitchPinsConversationModel() throws Exception {
Fixture f = new Fixture();
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max"), model("anthropic", "claude-sonnet-5")));
f.process("/model qwen-max");
// Magic commands run before processMessage's get-or-create, so the
// switch must ensure the row exists before pinning otherwise a
// /model sent as the very first message is silently lost.
verify(f.conversationService).getOrCreateSharedConversation("wecom:alice", 100L, null);
verify(f.conversationService).updateConversationModel("wecom:alice", "dashscope", "qwen-max");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("已切换"));
f.verifyAgentNeverCalled();
}
@Test
@DisplayName("ambiguous bare model name asks for a provider prefix instead of guessing")
void modelSwitchAmbiguousAsksForPrefix() throws Exception {
Fixture f = new Fixture();
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max"), model("mirror", "qwen-max")));
f.process("/model qwen-max");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("多个 provider"));
verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString());
}
@Test
@DisplayName("model list caps rows and points to keyword search")
void modelListCapsRows() throws Exception {
Fixture f = new Fixture();
List<ModelConfigEntity> many = new ArrayList<>();
for (int i = 0; i < 25; i++) {
many.add(model("p" + i, "m" + i));
}
when(f.modelConfigService.listEnabledModels()).thenReturn(many);
f.process("/model");
// A 180+-row catalog would otherwise segment into several IM bubbles.
verify(f.adapter).renderAndSend(eq("reply-1"), argThat(text ->
text.contains("共 25 个") && !text.contains("- p24:m24")));
}
@Test
@DisplayName("no exact match but partial hits → fuzzy suggestions, no pin")
void modelSwitchFuzzySuggests() throws Exception {
Fixture f = new Fixture();
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max"), model("dashscope", "qwen-plus"),
model("openai", "gpt-4o")));
f.process("/model qwen");
verify(f.adapter).renderAndSend(eq("reply-1"), argThat(text ->
text.contains("相近") && text.contains("qwen-max")
&& text.contains("qwen-plus") && !text.contains("gpt-4o")));
verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString());
}
@Test
@DisplayName("model reset clears the conversation pin")
void modelResetClearsPin() throws Exception {
Fixture f = new Fixture();
f.process("/model reset");
verify(f.conversationService).clearConversationModel("wecom:alice");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("恢复默认"));
verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString());
}
@Test
@DisplayName("unknown model name replies with a lookup hint, never pins")
void modelSwitchUnknownName() throws Exception {
Fixture f = new Fixture();
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max")));
f.process("/model gpt-99");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("未找到"));
verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString());
}
@Test
@DisplayName("model switch without a bound agent replies binding hint")
void modelSwitchWithoutAgent() throws Exception {
Fixture f = new Fixture();
f.channel.setAgentId(null);
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max")));
f.process("/model qwen-max");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("未绑定"));
verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString());
}
// ==================== helpers ====================
private static ModelConfigEntity model(String provider, String name) {
ModelConfigEntity m = new ModelConfigEntity();
m.setProvider(provider);
m.setModelName(name);
return m;
}
private static void assertParsed(String text, ChannelMagicCommand.Type expected) {
Optional<ChannelMagicCommand.Parsed> parsed = ChannelMagicCommand.parse(text);
assertTrue(parsed.isPresent(), "expected command match for: " + text);
@ -345,12 +200,11 @@ class ChannelMagicCommandTest {
final AgentService agentService = mock(AgentService.class);
final ConversationService conversationService = mock(ConversationService.class);
final ChatStreamTracker streamTracker = mock(ChatStreamTracker.class);
final ModelConfigService modelConfigService = mock(ModelConfigService.class);
final ChannelAdapter adapter = mock(ChannelAdapter.class);
final ChannelEntity channel = new ChannelEntity();
final ChannelMessageRouter router;
Fixture() throws Exception {
Fixture() {
ChannelService channelService = mock(ChannelService.class);
ChannelSessionStore channelSessionStore = mock(ChannelSessionStore.class);
ApprovalWorkflowService approvalService = mock(ApprovalWorkflowService.class);
@ -365,11 +219,6 @@ class ChannelMagicCommandTest {
chatOriginFactory, errorClassifier);
when(adapter.getChannelType()).thenReturn("wecom");
channel.setAgentId(100L);
// modelConfigService is field-injected on the real router (optional
// dep); mirror that wiring here via reflection.
Field mcs = ChannelMessageRouter.class.getDeclaredField("modelConfigService");
mcs.setAccessible(true);
mcs.set(router, modelConfigService);
}
void process(String content) throws Exception {