diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index 66f917ca..67716eb6 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -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); diff --git a/mateclaw-server/src/main/java/vip/mate/approval/grant/repository/ApprovalGrantMapper.java b/mateclaw-server/src/main/java/vip/mate/approval/grant/repository/ApprovalGrantMapper.java index f041de68..d7c1f888 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/grant/repository/ApprovalGrantMapper.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/grant/repository/ApprovalGrantMapper.java @@ -49,6 +49,21 @@ public interface ApprovalGrantMapper extends BaseMapper { @Param("candidateRuleIds") List 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 candidateRuleIds); + /** * Soft-revokes every active {@code UNTIL_CONVERSATION_END} grant attached to the given * conversation. Called by {@code ConversationLifecycleListener} on diff --git a/mateclaw-server/src/main/java/vip/mate/approval/grant/service/ApprovalGrantResolver.java b/mateclaw-server/src/main/java/vip/mate/approval/grant/service/ApprovalGrantResolver.java index 69dc5824..c57ea0ea 100644 --- a/mateclaw-server/src/main/java/vip/mate/approval/grant/service/ApprovalGrantResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/approval/grant/service/ApprovalGrantResolver.java @@ -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"); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java index 3e3e0e4f..0ce3b0a6 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java @@ -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 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))) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 461a7051..7ee3d4af 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -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 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 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 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 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 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) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java index debb6bf0..26c45468 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/model/ToolGuardAuditLogEntity.java @@ -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:<pattern> / SEVERITY_CRITICAL / + * SEVERITY_CEILING:<ceiling><<actual> / 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; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardAuditService.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardAuditService.java index fa3d7e22..008907d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardAuditService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardAuditService.java @@ -43,6 +43,18 @@ public class ToolGuardAuditService { */ @Async public void record(ToolInvocationContext context, GuardEvaluation evaluation, String pendingId) { + record(context, evaluation, pendingId, null); + } + + /** + * 记录审计日志并附带自动批准决策结果。 + *

+ * {@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)); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardService.java b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardService.java index b8a57a54..2e6c53a2 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/guard/service/ToolGuardService.java @@ -32,6 +32,18 @@ public class ToolGuardService { * 通过后再委托 ToolGuardEngine 做 Guardian 规则评估。 */ public GuardEvaluation evaluate(ToolInvocationContext context) { + return evaluate(context, false); + } + + /** + * 评估工具调用,可选延迟 NEEDS_APPROVAL 行的审计记录。 + *

+ * {@code deferApprovalAudit=true} 时,NEEDS_APPROVAL 结果不在此处落审计—— + * 调用方在 auto-grant 决策完成后通过 + * {@link #recordApprovalAudit(ToolInvocationContext, GuardEvaluation, String, String)} + * 补记一行,行内带上决策结果码与 pendingId。ALLOW / 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()); + } + } + /** * 便捷评估方法 */ diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index dab5143a..26ec584b 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -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() - .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 diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V170__guard_audit_auto_approve_outcome.sql b/mateclaw-server/src/main/resources/db/migration/h2/V170__guard_audit_auto_approve_outcome.sql new file mode 100644 index 00000000..83045558 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V170__guard_audit_auto_approve_outcome.sql @@ -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: / SEVERITY_CRITICAL / +-- SEVERITY_CEILING:< / UNKNOWN_WORKSPACE / NO_GRANT. + +ALTER TABLE mate_tool_guard_audit_log ADD COLUMN IF NOT EXISTS auto_approve_outcome VARCHAR(64) NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V170__guard_audit_auto_approve_outcome.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V170__guard_audit_auto_approve_outcome.sql new file mode 100644 index 00000000..1284da8a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V170__guard_audit_auto_approve_outcome.sql @@ -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; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V170__guard_audit_auto_approve_outcome.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V170__guard_audit_auto_approve_outcome.sql new file mode 100644 index 00000000..bb42da97 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V170__guard_audit_auto_approve_outcome.sql @@ -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; diff --git a/mateclaw-server/src/main/resources/mapper/ApprovalGrantMapper.xml b/mateclaw-server/src/main/resources/mapper/ApprovalGrantMapper.xml index cc66f5ff..98189f69 100644 --- a/mateclaw-server/src/main/resources/mapper/ApprovalGrantMapper.xml +++ b/mateclaw-server/src/main/resources/mapper/ApprovalGrantMapper.xml @@ -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. --> + + +