mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(approval): grant-based auto-approve with safety floor and resolution log
This commit is contained in:
parent
38dce5c20a
commit
b7e923fac4
@ -127,6 +127,18 @@ public class AgentGraphBuilder {
|
||||
private final vip.mate.goal.service.GoalFollowupService goalFollowupService;
|
||||
private final vip.mate.goal.config.GoalProperties goalProperties;
|
||||
|
||||
/**
|
||||
* Auto-grant resolver wired into the executor so an active
|
||||
* {@code mate_approval_grant} row can skip {@code createPending()} for matching
|
||||
* tool calls. Together with {@link #workspaceLookupCache}, these two deps form
|
||||
* the auto-grant entry point; the executor's null-guard turns the feature off
|
||||
* cleanly if either is missing.
|
||||
*/
|
||||
private final vip.mate.approval.grant.service.ApprovalGrantResolver approvalGrantResolver;
|
||||
|
||||
/** Conversation→workspaceId lookup cache; see {@link #approvalGrantResolver}. */
|
||||
private final vip.mate.approval.grant.WorkspaceLookupCache workspaceLookupCache;
|
||||
|
||||
/**
|
||||
* Optional audit pipeline. Setter injection (rather than a constructor
|
||||
* parameter) keeps existing constructor-based wiring + tests intact.
|
||||
@ -510,7 +522,10 @@ public class AgentGraphBuilder {
|
||||
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
|
||||
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
|
||||
providerPool);
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||
toolSet, toolGuardService, approvalService, streamTracker,
|
||||
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
||||
workspaceLookupCache, approvalGrantResolver);
|
||||
// Issue #46: enable skill-aware "Tool not found" hint so when the
|
||||
// LLM mis-calls a skill name as a tool, the response tells it
|
||||
// the right invocation pattern instead of a dead-end error.
|
||||
@ -752,7 +767,10 @@ public class AgentGraphBuilder {
|
||||
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
|
||||
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
|
||||
providerPool);
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||
toolSet, toolGuardService, approvalService, streamTracker,
|
||||
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
|
||||
workspaceLookupCache, approvalGrantResolver);
|
||||
// Issue #46: enable skill-aware "Tool not found" hint so when the
|
||||
// LLM mis-calls a skill name as a tool, the response tells it
|
||||
// the right invocation pattern instead of a dead-end error.
|
||||
|
||||
@ -14,6 +14,9 @@ import vip.mate.agent.context.StructuredTruncator;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
import vip.mate.approval.ApprovalWorkflowService;
|
||||
import vip.mate.approval.grant.AutoApproveResult;
|
||||
import vip.mate.approval.grant.WorkspaceLookupCache;
|
||||
import vip.mate.approval.grant.service.ApprovalGrantResolver;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
import vip.mate.tool.guard.ToolExecutionGuardHelper;
|
||||
import vip.mate.tool.guard.ToolGuard;
|
||||
@ -226,6 +229,38 @@ public class ToolExecutionExecutor {
|
||||
this.auditEventService = s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-grant lookup cache. Optional — legacy constructors leave it
|
||||
* {@code null} and {@code evaluateGuard()} falls back to the original
|
||||
* human-approval path. Both this and {@link #approvalGrantResolver} must be
|
||||
* non-null for auto-grant to engage; either being null disables the resolver
|
||||
* branch entirely (see {@code autoGrantWired} in {@code evaluateGuard}).
|
||||
* Not {@code final} so existing constructors that don't take these
|
||||
* dependencies stay source-compatible without restructuring.
|
||||
*/
|
||||
private WorkspaceLookupCache workspaceLookupCache;
|
||||
|
||||
/** Auto-grant resolver. Optional; see {@link #workspaceLookupCache} note. */
|
||||
private ApprovalGrantResolver approvalGrantResolver;
|
||||
|
||||
/**
|
||||
* Constructor used by {@code AgentGraphBuilder} after PR-1: takes the auto-grant
|
||||
* dependencies on top of the standard 7 params. Legacy constructors continue
|
||||
* to work unchanged (they simply leave the two new fields {@code null}).
|
||||
*/
|
||||
public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService,
|
||||
ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker,
|
||||
vip.mate.config.ToolTimeoutProperties toolTimeoutProperties,
|
||||
ToolResultStorage resultStorage,
|
||||
vip.mate.tool.ToolConcurrencyRegistry concurrencyRegistry,
|
||||
WorkspaceLookupCache workspaceLookupCache,
|
||||
ApprovalGrantResolver approvalGrantResolver) {
|
||||
this(toolSet, toolGuardService, null, approvalService, streamTracker,
|
||||
toolTimeoutProperties, resultStorage, concurrencyRegistry);
|
||||
this.workspaceLookupCache = workspaceLookupCache;
|
||||
this.approvalGrantResolver = approvalGrantResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-turn deduplication key set for child-agent denial audit. Without
|
||||
* this, a child that retries the same denied tool many times in one
|
||||
@ -899,7 +934,18 @@ public class ToolExecutionExecutor {
|
||||
String conversationId, String agentId,
|
||||
List<AssistantMessage.ToolCall> allToolCalls, int currentIndex,
|
||||
List<GraphEventPublisher.GraphEvent> events, String requesterId) {
|
||||
ToolInvocationContext guardCtx = ToolInvocationContext.of(toolName, arguments, conversationId, agentId);
|
||||
// Auto-grant requires BOTH the lookup cache and the resolver to be wired.
|
||||
// Legacy constructors leave them null; in that case we skip workspace
|
||||
// resolution and skip the resolver block, falling back to the original
|
||||
// human-approval path.
|
||||
boolean autoGrantWired = approvalGrantResolver != null && workspaceLookupCache != null;
|
||||
Long workspaceId = autoGrantWired
|
||||
? workspaceLookupCache.resolveByConversation(conversationId)
|
||||
: null;
|
||||
ToolInvocationContext guardCtx = ToolInvocationContext.of(
|
||||
toolName, java.util.Map.of(), arguments,
|
||||
conversationId, agentId,
|
||||
/*channelType*/ null, requesterId, workspaceId);
|
||||
|
||||
if (toolGuardService != null) {
|
||||
GuardEvaluation evaluation = toolGuardService.evaluate(guardCtx);
|
||||
@ -912,6 +958,27 @@ public class ToolExecutionExecutor {
|
||||
}
|
||||
|
||||
if (evaluation.shouldRequireApproval()) {
|
||||
// Auto-grant decision layer: only engages when both deps are wired.
|
||||
// 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.
|
||||
if (autoGrantWired) {
|
||||
AutoApproveResult auto = approvalGrantResolver.tryAutoApprove(guardCtx, evaluation);
|
||||
if (auto.isHardBlocked()) {
|
||||
String msg = "[安全拦截] safety floor matched: " + auto.reason()
|
||||
+ " — this command cannot be executed even with approval. "
|
||||
+ "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));
|
||||
return GuardDecision.blocked(msg);
|
||||
}
|
||||
if (auto.isApproved()) {
|
||||
log.info("[ToolExecutor] Auto-grant APPROVED: tool={}, grantId={}", toolName, auto.grantId());
|
||||
return GuardDecision.allowed();
|
||||
}
|
||||
// requiresHuman → fall through to legacy human-approval path below.
|
||||
}
|
||||
|
||||
List<AssistantMessage.ToolCall> remaining = allToolCalls.subList(currentIndex + 1, allToolCalls.size());
|
||||
String approvalResponse = ToolExecutionGuardHelper.handleToolApproval(
|
||||
toolCall, toolName, arguments, evaluation,
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
package vip.mate.approval.grant;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||
import vip.mate.tool.guard.model.GuardEvaluation;
|
||||
import vip.mate.tool.guard.model.ToolInvocationContext;
|
||||
|
||||
/**
|
||||
* WARN-level audit logger for the three resolver outcomes that need operator
|
||||
* visibility: AUTO_GRANT (a stored grant let a tool through), HARD_BLOCK (the
|
||||
* safety floor blocked a disaster) and FORCE_HUMAN (a dangerous pattern was
|
||||
* downgraded back to manual approval).
|
||||
* <p>
|
||||
* The logger is intentionally a separate bean with a fixed name
|
||||
* ({@code vip.mate.approval.grant.AutoApproveAuditLogger}) so operations can
|
||||
* filter / route just this signal without grepping through generic guard logs.
|
||||
* Arguments are truncated to 200 characters to keep each entry to one line; the
|
||||
* DB column {@code mate_approval_resolution_log.args_preview} stores up to 500
|
||||
* for the detail page.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AutoApproveAuditLogger {
|
||||
|
||||
private static final int LOG_ARGS_MAX = 200;
|
||||
|
||||
public void logAutoGrant(ApprovalGrant grant, ToolInvocationContext ctx, GuardEvaluation evaluation) {
|
||||
log.warn("[APPROVAL] AUTO_GRANT grantId={} tool={} severity={} (ceiling={}) "
|
||||
+ "scope={}/{} workspaceId={} args({})={} userId={} conversationId={} ruleId={}",
|
||||
grant.getId(),
|
||||
ctx.toolName(),
|
||||
severityName(evaluation),
|
||||
grant.getMaxSeverity(),
|
||||
grant.getScopeType(), grant.getScopeId(),
|
||||
ctx.workspaceId(),
|
||||
LOG_ARGS_MAX, truncate(ctx.rawArguments()),
|
||||
ctx.userId(), ctx.conversationId(),
|
||||
primaryRuleId(evaluation));
|
||||
}
|
||||
|
||||
public void logHardBlock(ToolInvocationContext ctx, GuardEvaluation evaluation, String patternName) {
|
||||
log.warn("[APPROVAL] HARD_BLOCK pattern={} tool={} args({})={} userId={} conversationId={}",
|
||||
patternName,
|
||||
ctx.toolName(),
|
||||
LOG_ARGS_MAX, truncate(ctx.rawArguments()),
|
||||
ctx.userId(), ctx.conversationId());
|
||||
}
|
||||
|
||||
public void logForceHuman(ToolInvocationContext ctx, GuardEvaluation evaluation, String patternName) {
|
||||
log.warn("[APPROVAL] FORCE_HUMAN pattern={} tool={} args({})={} userId={} conversationId={} "
|
||||
+ "— falling back to existing approval flow",
|
||||
patternName,
|
||||
ctx.toolName(),
|
||||
LOG_ARGS_MAX, truncate(ctx.rawArguments()),
|
||||
ctx.userId(), ctx.conversationId());
|
||||
}
|
||||
|
||||
private static String truncate(String s) {
|
||||
if (s == null) return "";
|
||||
return s.length() <= LOG_ARGS_MAX ? s : s.substring(0, LOG_ARGS_MAX) + "…";
|
||||
}
|
||||
|
||||
private static String severityName(GuardEvaluation evaluation) {
|
||||
return evaluation == null || evaluation.maxSeverity() == null
|
||||
? "UNKNOWN"
|
||||
: evaluation.maxSeverity().name();
|
||||
}
|
||||
|
||||
private static String primaryRuleId(GuardEvaluation evaluation) {
|
||||
if (evaluation == null || evaluation.findings() == null || evaluation.findings().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return evaluation.findings().get(0).ruleId();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package vip.mate.approval.grant;
|
||||
|
||||
/**
|
||||
* Tri-state outcome of {@code ApprovalGrantResolver.tryAutoApprove(...)}.
|
||||
* <p>
|
||||
* The resolver never throws on a missing grant or a fallback condition; it
|
||||
* returns one of these three states and lets the caller
|
||||
* ({@code ToolExecutionExecutor.evaluateGuard()}) map them to the right
|
||||
* {@code GuardDecision}.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #approved(Long)} — caller skips {@code createPending(...)} and
|
||||
* runs the tool directly. Carries the matched grant id.</li>
|
||||
* <li>{@link #hardBlocked(String)} — caller returns
|
||||
* {@code GuardDecision.blocked(...)}. No approval banner. Carries the
|
||||
* hard-floor pattern name for log/audit context.</li>
|
||||
* <li>{@link #requiresHuman(String)} — caller falls back to the existing
|
||||
* human approval flow. The {@code reason} is a short tag (e.g.
|
||||
* {@code "FORCE_HUMAN:pipe_shell"}, {@code "SEVERITY_CRITICAL"},
|
||||
* {@code "UNKNOWN_WORKSPACE"}, {@code "NO_GRANT"}) for logging.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class AutoApproveResult {
|
||||
|
||||
private enum State { APPROVED, HARD_BLOCKED, REQUIRES_HUMAN }
|
||||
|
||||
private final State state;
|
||||
private final Long grantId;
|
||||
private final String reason;
|
||||
|
||||
private AutoApproveResult(State state, Long grantId, String reason) {
|
||||
this.state = state;
|
||||
this.grantId = grantId;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public static AutoApproveResult approved(Long grantId) {
|
||||
return new AutoApproveResult(State.APPROVED, grantId, null);
|
||||
}
|
||||
|
||||
public static AutoApproveResult hardBlocked(String reason) {
|
||||
return new AutoApproveResult(State.HARD_BLOCKED, null, reason);
|
||||
}
|
||||
|
||||
public static AutoApproveResult requiresHuman(String reason) {
|
||||
return new AutoApproveResult(State.REQUIRES_HUMAN, null, reason);
|
||||
}
|
||||
|
||||
public boolean isApproved() { return state == State.APPROVED; }
|
||||
public boolean isHardBlocked() { return state == State.HARD_BLOCKED; }
|
||||
public boolean isRequiresHuman() { return state == State.REQUIRES_HUMAN; }
|
||||
|
||||
/** Non-null only when {@link #isApproved()} is true. */
|
||||
public Long grantId() { return grantId; }
|
||||
|
||||
/** Non-null when {@link #isHardBlocked()} or {@link #isRequiresHuman()}. */
|
||||
public String reason() { return reason; }
|
||||
}
|
||||
Binary file not shown.
@ -0,0 +1,77 @@
|
||||
package vip.mate.approval.grant;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Caffeine-backed cache for {@code conversationId → workspaceId} lookups on the
|
||||
* tool-call hot path.
|
||||
* <p>
|
||||
* The conversation→workspace mapping is immutable once a conversation is created,
|
||||
* so a 5-minute TTL is purely a bound on cache size, not a correctness guard.
|
||||
* Cache misses query MyBatis with a {@code LambdaQueryWrapper} on the
|
||||
* {@code conversation_id} business column — calling
|
||||
* {@code conversationMapper.selectById(stringConversationId)} would interpret the
|
||||
* string as the {@code Long} {@code @TableId} primary key and silently miss every
|
||||
* row, which would route every tool call to {@code UNKNOWN_WORKSPACE} and disable
|
||||
* auto-grant entirely.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class WorkspaceLookupCache {
|
||||
|
||||
private final ConversationMapper conversationMapper;
|
||||
|
||||
private final Cache<String, Long> cache = Caffeine.newBuilder()
|
||||
.maximumSize(5_000)
|
||||
.expireAfterWrite(Duration.ofMinutes(5))
|
||||
.build();
|
||||
|
||||
/**
|
||||
* Returns the workspaceId for the given business conversation id, or {@code null}
|
||||
* if the conversation does not exist (or was soft-deleted).
|
||||
*/
|
||||
public Long resolveByConversation(String conversationId) {
|
||||
if (conversationId == null || conversationId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return cache.get(conversationId, id -> {
|
||||
ConversationEntity conv = conversationMapper.selectOne(
|
||||
Wrappers.<ConversationEntity>lambdaQuery()
|
||||
.eq(ConversationEntity::getConversationId, id)
|
||||
.eq(ConversationEntity::getDeleted, 0)
|
||||
.last("LIMIT 1")
|
||||
);
|
||||
if (conv == null) {
|
||||
log.debug("[APPROVAL] WorkspaceLookupCache: conversation {} not found, returning null", id);
|
||||
return null;
|
||||
}
|
||||
return conv.getWorkspaceId();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a single mapping. Called by the lifecycle listener on
|
||||
* {@code ConversationDeletedEvent} so a re-created conversation with the same id
|
||||
* does not inherit a stale workspace.
|
||||
*/
|
||||
public void invalidate(String conversationId) {
|
||||
if (conversationId != null) {
|
||||
cache.invalidate(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook. */
|
||||
long estimatedSize() {
|
||||
return cache.estimatedSize();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package vip.mate.approval.grant.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Auto-approve grant entity.
|
||||
* <p>
|
||||
* Each row authorizes {@code ApprovalGrantResolver} to skip the manual approval
|
||||
* step for tool calls matching {@code (scope_type, scope_id, tool_name?, rule_id?)}
|
||||
* up to a {@code max_severity} ceiling. Hard-floor patterns still block irrespective
|
||||
* of any grant.
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_approval_grant")
|
||||
public class ApprovalGrant {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private Long workspaceId;
|
||||
|
||||
/** USER | AGENT | CONVERSATION | WORKSPACE — see {@link ScopeType}. */
|
||||
private String scopeType;
|
||||
|
||||
/** Snowflake string per CLAUDE.md precision convention. */
|
||||
private String scopeId;
|
||||
|
||||
/** Null = any tool (only valid when granted by workspace admin with password confirmation). */
|
||||
private String toolName;
|
||||
|
||||
/**
|
||||
* Matches the {@code String ruleId} on {@code GuardFinding}. Null = any rule
|
||||
* (grant applies to all findings under the severity ceiling).
|
||||
*/
|
||||
private String ruleId;
|
||||
|
||||
/** LOW | MEDIUM | HIGH. CRITICAL is rejected at API/UI; resolver never reaches a grant for it. */
|
||||
private String maxSeverity;
|
||||
|
||||
/** ALWAYS | UNTIL_TIMESTAMP | UNTIL_CONVERSATION_END — see {@link GrantKind}. */
|
||||
private String grantKind;
|
||||
|
||||
/** Only meaningful when {@code grantKind = UNTIL_TIMESTAMP}. */
|
||||
private LocalDateTime expireAt;
|
||||
|
||||
private Long grantedBy;
|
||||
|
||||
private LocalDateTime grantedAt;
|
||||
|
||||
private Integer revoked;
|
||||
|
||||
private Long revokedBy;
|
||||
|
||||
private LocalDateTime revokedAt;
|
||||
|
||||
private String note;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
private Integer deleted;
|
||||
|
||||
/** Allowed values for {@link #scopeType}; kept as constants to avoid string typos. */
|
||||
public static final class ScopeType {
|
||||
public static final String USER = "USER";
|
||||
public static final String AGENT = "AGENT";
|
||||
public static final String CONVERSATION = "CONVERSATION";
|
||||
public static final String WORKSPACE = "WORKSPACE";
|
||||
private ScopeType() {}
|
||||
}
|
||||
|
||||
/** Allowed values for {@link #grantKind}. */
|
||||
public static final class GrantKind {
|
||||
public static final String ALWAYS = "ALWAYS";
|
||||
public static final String UNTIL_TIMESTAMP = "UNTIL_TIMESTAMP";
|
||||
public static final String UNTIL_CONVERSATION_END = "UNTIL_CONVERSATION_END";
|
||||
private GrantKind() {}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package vip.mate.approval.grant.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Final decision log written by the approval layer (one row per resolved invocation).
|
||||
* <p>
|
||||
* Decoupled from {@code mate_tool_guard_audit_log} (which records guard evaluation
|
||||
* facts). Dashboard decision-source percentages read from this table only, so the
|
||||
* counts stay clean even when an invocation produces both an evaluation row and a
|
||||
* resolution row.
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_approval_resolution_log")
|
||||
public class ApprovalResolutionLog {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* Nullable: a {@code HARD_BLOCK} event can be recorded before the workspace
|
||||
* has been resolved (missing/deleted conversation, malformed context). Other
|
||||
* decision sources ({@code USER_MANUAL}, {@code AUTO_GRANT}, {@code TIMEOUT})
|
||||
* always have a known workspace by the time they reach this table.
|
||||
*/
|
||||
private Long workspaceId;
|
||||
|
||||
private String conversationId;
|
||||
|
||||
private String agentId;
|
||||
|
||||
private String userId;
|
||||
|
||||
/** Correlates to {@code AssistantMessage.ToolCall.id} when available; nullable. */
|
||||
private String toolCallId;
|
||||
|
||||
private String toolName;
|
||||
|
||||
private String maxSeverity;
|
||||
|
||||
/** Comma-joined list of GuardFinding ruleIds present at decision time. */
|
||||
private String ruleIds;
|
||||
|
||||
/** USER_MANUAL | AUTO_GRANT | HARD_BLOCK | TIMEOUT — see {@link DecisionSource}. */
|
||||
private String decisionSource;
|
||||
|
||||
/** Non-null when {@code decisionSource = AUTO_GRANT}. */
|
||||
private Long grantId;
|
||||
|
||||
/** Non-null when the path went through {@code ApprovalWorkflowService.createPending()}. */
|
||||
private String pendingId;
|
||||
|
||||
/** First 500 chars of rawArguments. WARN log prints 200; this stores more for the detail page. */
|
||||
private String argsPreview;
|
||||
|
||||
private String note;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private Integer deleted;
|
||||
|
||||
/** Allowed values for {@link #decisionSource}. */
|
||||
public static final class DecisionSource {
|
||||
public static final String USER_MANUAL = "USER_MANUAL";
|
||||
public static final String AUTO_GRANT = "AUTO_GRANT";
|
||||
public static final String HARD_BLOCK = "HARD_BLOCK";
|
||||
public static final String TIMEOUT = "TIMEOUT";
|
||||
private DecisionSource() {}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package vip.mate.approval.grant.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Mapper for {@link ApprovalGrant}.
|
||||
* <p>
|
||||
* BaseMapper covers ordinary CRUD; {@link #findFirstMatching} is a custom query
|
||||
* defined in {@code ApprovalGrantMapper.xml} that returns the best-matching active
|
||||
* grant for a tool invocation, ordered by scope priority and specificity.
|
||||
*/
|
||||
@Mapper
|
||||
public interface ApprovalGrantMapper extends BaseMapper<ApprovalGrant> {
|
||||
|
||||
/**
|
||||
* Returns the single best grant that authorizes the given tool invocation, or
|
||||
* {@code null} if none applies. Matching rules (see {@code ApprovalGrantMapper.xml}):
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code workspace_id} must equal {@code workspaceId} (tenant isolation, mandatory).</li>
|
||||
* <li>Not revoked, not deleted, not expired.</li>
|
||||
* <li>{@code max_severity} must be at least as high as {@code evalSeverity}.</li>
|
||||
* <li>{@code tool_name} is NULL or equals {@code toolName}.</li>
|
||||
* <li>{@code rule_id} is NULL or is in {@code candidateRuleIds} (when the list is non-empty).</li>
|
||||
* <li>One of the scope clauses must match: CONVERSATION+conversationId / AGENT+agentId /
|
||||
* USER+userId / WORKSPACE+workspaceScopeId.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Order: scope priority CONVERSATION > AGENT > USER > WORKSPACE,
|
||||
* then rule-id-specific over rule-id-null, then tool-name-specific over null. {@code LIMIT 1}.
|
||||
*
|
||||
* @param workspaceScopeId {@code String.valueOf(workspaceId)} — pre-converted to avoid
|
||||
* dialect-specific CAST in SQL (H2 vs MySQL).
|
||||
* @param candidateRuleIds list of GuardFinding ruleIds for the current invocation; may be empty
|
||||
* or null, in which case only {@code rule_id IS NULL} grants match.
|
||||
*/
|
||||
ApprovalGrant findFirstMatching(
|
||||
@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,
|
||||
@Param("evalSeverity") String evalSeverity);
|
||||
|
||||
/**
|
||||
* Soft-revokes every active {@code UNTIL_CONVERSATION_END} grant attached to the given
|
||||
* conversation. Called by {@code ConversationLifecycleListener} on
|
||||
* {@code ConversationDeletedEvent} (PR-2).
|
||||
*/
|
||||
int revokeUntilConversationEnd(@Param("conversationId") String conversationId);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.approval.grant.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.approval.grant.entity.ApprovalResolutionLog;
|
||||
|
||||
@Mapper
|
||||
public interface ApprovalResolutionLogMapper extends BaseMapper<ApprovalResolutionLog> {
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
package vip.mate.approval.grant.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.approval.grant.AutoApproveAuditLogger;
|
||||
import vip.mate.approval.grant.AutoApproveResult;
|
||||
import vip.mate.approval.grant.AutoGrantSafetyFloor;
|
||||
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||
import vip.mate.approval.grant.entity.ApprovalResolutionLog;
|
||||
import vip.mate.approval.grant.repository.ApprovalGrantMapper;
|
||||
import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper;
|
||||
import vip.mate.tool.guard.model.GuardEvaluation;
|
||||
import vip.mate.tool.guard.model.GuardFinding;
|
||||
import vip.mate.tool.guard.model.GuardSeverity;
|
||||
import vip.mate.tool.guard.model.ToolInvocationContext;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Decides whether a tool invocation that {@code ToolGuardService.evaluate(...)}
|
||||
* already flagged as {@code NEEDS_APPROVAL} can be auto-approved by a stored
|
||||
* {@link ApprovalGrant}, or must fall back to the existing human-approval flow,
|
||||
* or must be hard-blocked.
|
||||
* <p>
|
||||
* Decision order:
|
||||
* <ol>
|
||||
* <li>Safety floor — {@link AutoGrantSafetyFloor#evaluate(String)} short-circuits
|
||||
* on disasters ({@code HARD_BLOCK}) and downgrades dangerous-but-occasionally-
|
||||
* legitimate patterns to {@code FORCE_HUMAN} (skip grant lookup, fall back
|
||||
* to manual approval).</li>
|
||||
* <li>Severity ceiling — {@code CRITICAL} is never auto-approvable.</li>
|
||||
* <li>Tenant gate — when {@code workspaceId} is unknown the resolver
|
||||
* conservatively returns {@code requiresHuman("UNKNOWN_WORKSPACE")} rather
|
||||
* than letting a malformed context match the wrong workspace.</li>
|
||||
* <li>Grant lookup — every {@code ruleId} present on the findings is sent to
|
||||
* the mapper as a candidate; the mapper's SQL handles scope priority and
|
||||
* severity ceiling.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Whenever the resolver itself reaches a final decision (HARD_BLOCK or
|
||||
* AUTO_GRANT), it writes one row to {@code mate_approval_resolution_log}. For
|
||||
* {@code FORCE_HUMAN} / {@code SEVERITY_CRITICAL} / {@code UNKNOWN_WORKSPACE} /
|
||||
* {@code NO_GRANT} no row is written here — the row is added later by
|
||||
* {@code ApprovalWorkflowService.resolve*()} / {@code garbageCollect()} (PR-2)
|
||||
* once the human path actually completes.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApprovalGrantResolver {
|
||||
|
||||
private static final int ARGS_PREVIEW_MAX = 500;
|
||||
|
||||
private final ApprovalGrantMapper grantMapper;
|
||||
private final ApprovalResolutionLogMapper resolutionMapper;
|
||||
private final AutoGrantSafetyFloor safetyFloor;
|
||||
private final AutoApproveAuditLogger auditLogger;
|
||||
|
||||
public AutoApproveResult tryAutoApprove(ToolInvocationContext ctx, GuardEvaluation evaluation) {
|
||||
// 1) Safety floor — hard block or force the existing human path.
|
||||
AutoGrantSafetyFloor.SafetyFloorMatch sf = safetyFloor.evaluate(ctx.rawArguments());
|
||||
if (sf.action() == AutoGrantSafetyFloor.Action.HARD_BLOCK) {
|
||||
auditLogger.logHardBlock(ctx, evaluation, sf.patternName());
|
||||
resolutionMapper.insert(
|
||||
buildResolutionLog(ctx, evaluation,
|
||||
ApprovalResolutionLog.DecisionSource.HARD_BLOCK,
|
||||
null, null,
|
||||
"matched safety floor pattern: " + sf.patternName()));
|
||||
return AutoApproveResult.hardBlocked(sf.patternName());
|
||||
}
|
||||
if (sf.action() == AutoGrantSafetyFloor.Action.FORCE_HUMAN) {
|
||||
auditLogger.logForceHuman(ctx, evaluation, sf.patternName());
|
||||
return AutoApproveResult.requiresHuman("FORCE_HUMAN:" + sf.patternName());
|
||||
}
|
||||
|
||||
// 2) Severity ceiling — CRITICAL is never auto-approvable.
|
||||
if (evaluation != null && evaluation.maxSeverity() == GuardSeverity.CRITICAL) {
|
||||
return AutoApproveResult.requiresHuman("SEVERITY_CRITICAL");
|
||||
}
|
||||
|
||||
// 3) workspaceId required for tenant isolation; null → conservative human path.
|
||||
if (ctx.workspaceId() == null) {
|
||||
log.warn("[APPROVAL] workspaceId=null for conversation={} agent={} tool={} — "
|
||||
+ "falling back to human approval. Check WorkspaceLookupCache wiring.",
|
||||
ctx.conversationId(), ctx.agentId(), ctx.toolName());
|
||||
return AutoApproveResult.requiresHuman("UNKNOWN_WORKSPACE");
|
||||
}
|
||||
|
||||
// 4) Collect all candidate ruleIds from findings (for IN-clause matching).
|
||||
List<String> candidateRuleIds = (evaluation == null || evaluation.findings() == null)
|
||||
? List.of()
|
||||
: evaluation.findings().stream()
|
||||
.map(GuardFinding::ruleId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
// 5) Mapper finds first grant ordered by scope priority + specificity.
|
||||
// workspaceId is also passed as a string for the WORKSPACE-scope match,
|
||||
// so the mapper SQL stays dialect-clean (no CAST). See ApprovalGrantMapper.xml.
|
||||
String workspaceScopeId = String.valueOf(ctx.workspaceId());
|
||||
String evalSeverity = evaluation == null || evaluation.maxSeverity() == null
|
||||
? GuardSeverity.LOW.name()
|
||||
: evaluation.maxSeverity().name();
|
||||
|
||||
ApprovalGrant matched = grantMapper.findFirstMatching(
|
||||
ctx.workspaceId(),
|
||||
ctx.userId(), ctx.agentId(), ctx.conversationId(),
|
||||
workspaceScopeId,
|
||||
ctx.toolName(),
|
||||
candidateRuleIds,
|
||||
evalSeverity);
|
||||
|
||||
if (matched == null) {
|
||||
return AutoApproveResult.requiresHuman("NO_GRANT");
|
||||
}
|
||||
|
||||
// 6) Grant matched — log, audit, and approve.
|
||||
auditLogger.logAutoGrant(matched, ctx, evaluation);
|
||||
resolutionMapper.insert(
|
||||
buildResolutionLog(ctx, evaluation,
|
||||
ApprovalResolutionLog.DecisionSource.AUTO_GRANT,
|
||||
matched.getId(), null, matched.getNote()));
|
||||
return AutoApproveResult.approved(matched.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link ApprovalResolutionLog} row for the given final decision.
|
||||
* {@code grantId} is set only for AUTO_GRANT; {@code pendingId} is set only when
|
||||
* the row is later written by the human-path hooks in
|
||||
* {@code ApprovalWorkflowService} (PR-2).
|
||||
*/
|
||||
private ApprovalResolutionLog buildResolutionLog(ToolInvocationContext ctx,
|
||||
GuardEvaluation evaluation,
|
||||
String decisionSource,
|
||||
Long grantId,
|
||||
String pendingId,
|
||||
String note) {
|
||||
ApprovalResolutionLog row = new ApprovalResolutionLog();
|
||||
row.setWorkspaceId(ctx.workspaceId());
|
||||
row.setConversationId(ctx.conversationId());
|
||||
row.setAgentId(ctx.agentId());
|
||||
row.setUserId(ctx.userId());
|
||||
row.setToolName(ctx.toolName());
|
||||
row.setMaxSeverity(evaluation == null || evaluation.maxSeverity() == null
|
||||
? null : evaluation.maxSeverity().name());
|
||||
row.setRuleIds(joinRuleIds(evaluation));
|
||||
row.setDecisionSource(decisionSource);
|
||||
row.setGrantId(grantId);
|
||||
row.setPendingId(pendingId);
|
||||
row.setArgsPreview(previewArgs(ctx.rawArguments()));
|
||||
row.setNote(note);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static String joinRuleIds(GuardEvaluation evaluation) {
|
||||
if (evaluation == null || evaluation.findings() == null || evaluation.findings().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return evaluation.findings().stream()
|
||||
.map(GuardFinding::ruleId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.reduce((a, b) -> a + "," + b)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static String previewArgs(String raw) {
|
||||
if (raw == null) return null;
|
||||
return raw.length() <= ARGS_PREVIEW_MAX ? raw : raw.substring(0, ARGS_PREVIEW_MAX);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
package vip.mate.approval.grant.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||
import vip.mate.approval.grant.repository.ApprovalGrantMapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Service-layer operations on {@link ApprovalGrant}.
|
||||
* <p>
|
||||
* CRUD is handled via the {@link ApprovalGrantMapper} BaseMapper; this service
|
||||
* adds the small number of approval-domain operations that callers from outside
|
||||
* the controller need:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #revokeConversationScopedGrants(String)} — used by the lifecycle
|
||||
* listener (PR-2) on {@code ConversationDeletedEvent} to soft-revoke every
|
||||
* {@code UNTIL_CONVERSATION_END} grant attached to that conversation.</li>
|
||||
* <li>{@link #countActiveInWorkspace(Long)} — used by the {@code /api/v1/approval/grants/active}
|
||||
* endpoint and the front-end pill / chip so they can show {@code (N)} without
|
||||
* fetching every row.</li>
|
||||
* <li>{@link #listActiveByScope(Long, String)} — generic listing for the
|
||||
* management page, with the standard "not deleted, not revoked, not expired"
|
||||
* filter applied uniformly.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>CRUD validation (e.g. rejecting {@code max_severity=CRITICAL} or enforcing the
|
||||
* scope/tool_name authorization matrix in §2.4.5) lives in {@code ApprovalGrantController}
|
||||
* (PR-4), not here — the service stays low-policy so {@code ApprovalGrantResolver}
|
||||
* can call it without dragging REST concerns in.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApprovalGrantService {
|
||||
|
||||
private final ApprovalGrantMapper grantMapper;
|
||||
|
||||
/**
|
||||
* Soft-revokes every active {@code UNTIL_CONVERSATION_END} grant attached to the
|
||||
* conversation. Idempotent.
|
||||
*/
|
||||
@Transactional
|
||||
public int revokeConversationScopedGrants(String conversationId) {
|
||||
if (conversationId == null || conversationId.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int revoked = grantMapper.revokeUntilConversationEnd(conversationId);
|
||||
if (revoked > 0) {
|
||||
log.info("[APPROVAL] Revoked {} UNTIL_CONVERSATION_END grant(s) on conversation delete: {}",
|
||||
revoked, conversationId);
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/** Counts active grants visible in the given workspace (drives the chip "(N)"). */
|
||||
public long countActiveInWorkspace(Long workspaceId) {
|
||||
if (workspaceId == null) return 0;
|
||||
return grantMapper.selectCount(
|
||||
Wrappers.<ApprovalGrant>lambdaQuery()
|
||||
.eq(ApprovalGrant::getWorkspaceId, workspaceId)
|
||||
.eq(ApprovalGrant::getRevoked, 0)
|
||||
.eq(ApprovalGrant::getDeleted, 0)
|
||||
.and(w -> w.isNull(ApprovalGrant::getExpireAt)
|
||||
.or().gt(ApprovalGrant::getExpireAt, LocalDateTime.now()))
|
||||
);
|
||||
}
|
||||
|
||||
/** Lists active grants in a workspace, optionally restricted to a scope type. */
|
||||
public List<ApprovalGrant> listActiveByScope(Long workspaceId, String scopeType) {
|
||||
if (workspaceId == null) return List.of();
|
||||
var wrapper = Wrappers.<ApprovalGrant>lambdaQuery()
|
||||
.eq(ApprovalGrant::getWorkspaceId, workspaceId)
|
||||
.eq(ApprovalGrant::getRevoked, 0)
|
||||
.eq(ApprovalGrant::getDeleted, 0)
|
||||
.and(w -> w.isNull(ApprovalGrant::getExpireAt)
|
||||
.or().gt(ApprovalGrant::getExpireAt, LocalDateTime.now()))
|
||||
.orderByDesc(ApprovalGrant::getGrantedAt);
|
||||
if (scopeType != null && !scopeType.isEmpty()) {
|
||||
wrapper.eq(ApprovalGrant::getScopeType, scopeType);
|
||||
}
|
||||
return grantMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
/** Soft-revokes a single grant. Caller must enforce ownership / admin (PR-4). */
|
||||
@Transactional
|
||||
public boolean revoke(Long grantId, Long revokedBy) {
|
||||
ApprovalGrant g = grantMapper.selectById(grantId);
|
||||
if (g == null || g.getRevoked() != null && g.getRevoked() == 1) {
|
||||
return false;
|
||||
}
|
||||
g.setRevoked(1);
|
||||
g.setRevokedBy(revokedBy);
|
||||
g.setRevokedAt(LocalDateTime.now());
|
||||
return grantMapper.updateById(g) > 0;
|
||||
}
|
||||
}
|
||||
@ -3,10 +3,15 @@ package vip.mate.tool.guard.model;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工具调用上下文
|
||||
* Standard tool invocation context shared by every Guardian.
|
||||
* <p>
|
||||
* 标准化的工具调用信息,供所有 Guardian 使用。
|
||||
* 先标准化上下文,再做风险评估。
|
||||
* The {@code workspaceId} field was added so that {@code ApprovalGrantResolver}
|
||||
* can scope grant lookups by workspace without forcing a DB query inside the
|
||||
* resolver. Callers that already know the workspace pass it explicitly via
|
||||
* {@link #of(String, Map, String, String, String, String, String, Long)}.
|
||||
* Legacy callers using {@link #of(String, String, String, String)} receive
|
||||
* {@code workspaceId = null}; the resolver then conservatively falls back to
|
||||
* the existing human-approval path.
|
||||
*/
|
||||
public record ToolInvocationContext(
|
||||
String toolName,
|
||||
@ -15,28 +20,34 @@ public record ToolInvocationContext(
|
||||
String conversationId,
|
||||
String agentId,
|
||||
String channelType,
|
||||
String userId
|
||||
String userId,
|
||||
Long workspaceId
|
||||
) {
|
||||
|
||||
/**
|
||||
* 常用工厂方法 — 从工具名和原始参数创建
|
||||
* Legacy factory: workspaceId resolved lazily downstream (sets {@code null} here).
|
||||
* Kept verbatim so existing call sites and tests continue to compile.
|
||||
*/
|
||||
public static ToolInvocationContext of(String toolName, String rawArguments,
|
||||
String conversationId, String agentId) {
|
||||
return new ToolInvocationContext(
|
||||
toolName, Map.of(), rawArguments, conversationId, agentId, null, null
|
||||
);
|
||||
toolName, Map.of(), rawArguments, conversationId, agentId,
|
||||
null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整工厂方法
|
||||
* Full factory: preferred path used by {@code ToolExecutionExecutor.evaluateGuard()}
|
||||
* once {@code WorkspaceLookupCache.resolveByConversation(...)} has resolved the
|
||||
* workspace.
|
||||
*/
|
||||
public static ToolInvocationContext of(String toolName, Map<String, Object> parameters,
|
||||
String rawArguments, String conversationId,
|
||||
String agentId, String channelType, String userId) {
|
||||
String agentId, String channelType, String userId,
|
||||
Long workspaceId) {
|
||||
return new ToolInvocationContext(
|
||||
toolName, parameters != null ? parameters : Map.of(),
|
||||
rawArguments, conversationId, agentId, channelType, userId
|
||||
);
|
||||
toolName,
|
||||
parameters != null ? parameters : Map.of(),
|
||||
rawArguments, conversationId, agentId,
|
||||
channelType, userId, workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
-- V127: Approval auto-grant table.
|
||||
-- Holds user-authorized rules that let ApprovalGrantResolver bypass createPending()
|
||||
-- for matching tool calls. Each row is an explicit grant with a defined scope,
|
||||
-- optional tool/rule filter, and a severity ceiling. Hard-floor patterns still
|
||||
-- block irrespective of any grant.
|
||||
CREATE TABLE IF NOT EXISTS mate_approval_grant (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
workspace_id BIGINT NOT NULL,
|
||||
scope_type VARCHAR(32) NOT NULL, -- USER | AGENT | CONVERSATION | WORKSPACE
|
||||
scope_id VARCHAR(64) NOT NULL, -- snowflake string per CLAUDE.md precision convention
|
||||
tool_name VARCHAR(128), -- NULL = any tool (UI requires password confirm)
|
||||
rule_id VARCHAR(128), -- matches GuardFinding.ruleId; NULL = any rule
|
||||
max_severity VARCHAR(16) NOT NULL, -- LOW | MEDIUM | HIGH (CRITICAL rejected by API/UI)
|
||||
grant_kind VARCHAR(24) NOT NULL, -- ALWAYS | UNTIL_TIMESTAMP | UNTIL_CONVERSATION_END
|
||||
expire_at DATETIME, -- only when grant_kind = UNTIL_TIMESTAMP
|
||||
granted_by BIGINT NOT NULL,
|
||||
granted_at DATETIME NOT NULL,
|
||||
revoked TINYINT NOT NULL DEFAULT 0,
|
||||
revoked_by BIGINT,
|
||||
revoked_at DATETIME,
|
||||
note VARCHAR(500),
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted TINYINT NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_grant_scope
|
||||
ON mate_approval_grant(workspace_id, scope_type, scope_id, tool_name, revoked, deleted);
|
||||
CREATE INDEX IF NOT EXISTS idx_grant_expire
|
||||
ON mate_approval_grant(expire_at, revoked, deleted);
|
||||
@ -0,0 +1,35 @@
|
||||
-- V128: Approval resolution log table.
|
||||
-- Single source of truth for "approval-layer final decisions":
|
||||
-- USER_MANUAL / AUTO_GRANT / HARD_BLOCK / TIMEOUT. Decoupled from the existing
|
||||
-- mate_tool_guard_audit_log (which records guard evaluation facts), so Dashboard
|
||||
-- decision-source charts can compute clean percentages without double-counting.
|
||||
CREATE TABLE IF NOT EXISTS mate_approval_resolution_log (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
-- Nullable: a HARD_BLOCK event can fire before WorkspaceLookupCache has
|
||||
-- resolved a workspace (missing/deleted conversation, malformed context).
|
||||
-- Recording the safety event itself is more important than tying it to a
|
||||
-- workspace; per-workspace Dashboard panels filter with `workspace_id = ?`
|
||||
-- and naturally skip these rows, while the global "recent HARD_BLOCKs"
|
||||
-- panel still surfaces them.
|
||||
workspace_id BIGINT,
|
||||
conversation_id VARCHAR(128),
|
||||
agent_id VARCHAR(64),
|
||||
user_id VARCHAR(64),
|
||||
tool_call_id VARCHAR(64), -- correlates to AssistantMessage.ToolCall.id when available
|
||||
tool_name VARCHAR(128) NOT NULL,
|
||||
max_severity VARCHAR(16),
|
||||
rule_ids VARCHAR(512), -- comma-joined list of GuardFinding ruleIds
|
||||
decision_source VARCHAR(24) NOT NULL, -- USER_MANUAL | AUTO_GRANT | HARD_BLOCK | TIMEOUT
|
||||
grant_id BIGINT, -- non-null when decision_source = AUTO_GRANT
|
||||
pending_id VARCHAR(32), -- non-null when path went through createPending()
|
||||
args_preview VARCHAR(500), -- first 500 chars of rawArguments (WARN log prints 200)
|
||||
note VARCHAR(500),
|
||||
create_time DATETIME NOT NULL,
|
||||
deleted TINYINT NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_resolution_workspace_time
|
||||
ON mate_approval_resolution_log(workspace_id, create_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_resolution_grant
|
||||
ON mate_approval_resolution_log(grant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resolution_pending
|
||||
ON mate_approval_resolution_log(pending_id);
|
||||
@ -0,0 +1,25 @@
|
||||
-- V127: Approval auto-grant table (MySQL dialect).
|
||||
-- Idempotent: outer CREATE TABLE uses IF NOT EXISTS; inline KEY clauses
|
||||
-- only execute on first creation, so re-running this migration is safe.
|
||||
CREATE TABLE IF NOT EXISTS mate_approval_grant (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
workspace_id BIGINT NOT NULL,
|
||||
scope_type VARCHAR(32) NOT NULL,
|
||||
scope_id VARCHAR(64) NOT NULL,
|
||||
tool_name VARCHAR(128) DEFAULT NULL,
|
||||
rule_id VARCHAR(128) DEFAULT NULL,
|
||||
max_severity VARCHAR(16) NOT NULL,
|
||||
grant_kind VARCHAR(24) NOT NULL,
|
||||
expire_at DATETIME DEFAULT NULL,
|
||||
granted_by BIGINT NOT NULL,
|
||||
granted_at DATETIME NOT NULL,
|
||||
revoked TINYINT NOT NULL DEFAULT 0,
|
||||
revoked_by BIGINT DEFAULT NULL,
|
||||
revoked_at DATETIME DEFAULT NULL,
|
||||
note VARCHAR(500) DEFAULT NULL,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
KEY idx_grant_scope (workspace_id, scope_type, scope_id, tool_name, revoked, deleted),
|
||||
KEY idx_grant_expire (expire_at, revoked, deleted)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@ -0,0 +1,25 @@
|
||||
-- V128: Approval resolution log table (MySQL dialect).
|
||||
CREATE TABLE IF NOT EXISTS mate_approval_resolution_log (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
-- Nullable: HARD_BLOCK can fire before workspace resolution; see H2 migration
|
||||
-- for full rationale. Per-workspace Dashboard queries filter on workspace_id
|
||||
-- and skip null rows; the global HARD_BLOCK panel surfaces them.
|
||||
workspace_id BIGINT DEFAULT NULL,
|
||||
conversation_id VARCHAR(128) DEFAULT NULL,
|
||||
agent_id VARCHAR(64) DEFAULT NULL,
|
||||
user_id VARCHAR(64) DEFAULT NULL,
|
||||
tool_call_id VARCHAR(64) DEFAULT NULL,
|
||||
tool_name VARCHAR(128) NOT NULL,
|
||||
max_severity VARCHAR(16) DEFAULT NULL,
|
||||
rule_ids VARCHAR(512) DEFAULT NULL,
|
||||
decision_source VARCHAR(24) NOT NULL,
|
||||
grant_id BIGINT DEFAULT NULL,
|
||||
pending_id VARCHAR(32) DEFAULT NULL,
|
||||
args_preview VARCHAR(500) DEFAULT NULL,
|
||||
note VARCHAR(500) DEFAULT NULL,
|
||||
create_time DATETIME NOT NULL,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
KEY idx_resolution_workspace_time (workspace_id, create_time),
|
||||
KEY idx_resolution_grant (grant_id),
|
||||
KEY idx_resolution_pending (pending_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="vip.mate.approval.grant.repository.ApprovalGrantMapper">
|
||||
|
||||
<!--
|
||||
Returns the best-matching active grant for a tool invocation, or no row if none applies.
|
||||
|
||||
Severity ordering is expressed inline with a CASE expression rather than a database
|
||||
function (no FIELD() on H2, no severity_rank() in either dialect), so the same SQL
|
||||
runs on H2 and MySQL 8 unchanged.
|
||||
|
||||
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).
|
||||
-->
|
||||
<select id="findFirstMatching"
|
||||
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 > CURRENT_TIMESTAMP)
|
||||
AND
|
||||
CASE max_severity
|
||||
WHEN 'HIGH' THEN 3
|
||||
WHEN 'MEDIUM' THEN 2
|
||||
WHEN 'LOW' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
>=
|
||||
CASE #{evalSeverity}
|
||||
WHEN 'HIGH' THEN 3
|
||||
WHEN 'MEDIUM' THEN 2
|
||||
WHEN 'LOW' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
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.
|
||||
-->
|
||||
<update id="revokeUntilConversationEnd">
|
||||
UPDATE mate_approval_grant
|
||||
SET revoked = 1,
|
||||
revoked_at = CURRENT_TIMESTAMP,
|
||||
update_time = CURRENT_TIMESTAMP,
|
||||
note = CASE
|
||||
WHEN note IS NULL OR note = '' THEN '(auto-revoked on conversation delete)'
|
||||
ELSE CONCAT(note, ' (auto-revoked on conversation delete)')
|
||||
END
|
||||
WHERE deleted = 0
|
||||
AND revoked = 0
|
||||
AND grant_kind = 'UNTIL_CONVERSATION_END'
|
||||
AND scope_type = 'CONVERSATION'
|
||||
AND scope_id = #{conversationId}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@ -0,0 +1,80 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||
import vip.mate.approval.grant.WorkspaceLookupCache;
|
||||
import vip.mate.approval.grant.service.ApprovalGrantResolver;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Static-shape wiring check for the PR-1 integration points.
|
||||
* <p>
|
||||
* Earlier drafts used {@code @SpringBootTest} here, but that boots the full
|
||||
* application context (failing on the missing WebSocket {@code ServerContainer}
|
||||
* in the test classpath) and runs Flyway against the local dev file H2 — a
|
||||
* destructive side effect for a test whose only job is to verify that two fields
|
||||
* exist and one constructor signature is present.
|
||||
* <p>
|
||||
* Reflection covers exactly that: if anyone deletes a field on
|
||||
* {@code AgentGraphBuilder}, changes its type, or removes the new 9-arg
|
||||
* {@code ToolExecutionExecutor} constructor, this test fails immediately. No
|
||||
* Spring, no database, no provider keys required.
|
||||
* <p>
|
||||
* The "does Spring actually inject these beans at runtime" check moves to
|
||||
* PR-2's integration test, which already brings up the full context for the
|
||||
* conversation-lifecycle event listener.
|
||||
*/
|
||||
class AgentGraphBuilderIT {
|
||||
|
||||
@Test
|
||||
void agent_graph_builder_declares_auto_grant_fields() throws NoSuchFieldException {
|
||||
Field resolverField = AgentGraphBuilder.class.getDeclaredField("approvalGrantResolver");
|
||||
Field cacheField = AgentGraphBuilder.class.getDeclaredField("workspaceLookupCache");
|
||||
|
||||
assertThat(resolverField.getType()).isEqualTo(ApprovalGrantResolver.class);
|
||||
assertThat(cacheField.getType()).isEqualTo(WorkspaceLookupCache.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tool_execution_executor_has_constructor_that_accepts_auto_grant_deps() {
|
||||
boolean found = false;
|
||||
for (Constructor<?> c : ToolExecutionExecutor.class.getConstructors()) {
|
||||
Class<?>[] types = c.getParameterTypes();
|
||||
if (types.length >= 2
|
||||
&& types[types.length - 2] == WorkspaceLookupCache.class
|
||||
&& types[types.length - 1] == ApprovalGrantResolver.class) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertThat(found)
|
||||
.as("ToolExecutionExecutor must expose a public constructor whose last two "
|
||||
+ "parameters are WorkspaceLookupCache + ApprovalGrantResolver; otherwise "
|
||||
+ "AgentGraphBuilder's `new ToolExecutionExecutor(...)` call sites won't compile.")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tool_execution_executor_keeps_legacy_constructors() {
|
||||
// Five legacy public constructors stay so that legacy callers and tests
|
||||
// that don't know about auto-grant continue to compile and run.
|
||||
long legacyCount = 0;
|
||||
for (Constructor<?> c : ToolExecutionExecutor.class.getConstructors()) {
|
||||
Class<?>[] types = c.getParameterTypes();
|
||||
boolean isAutoGrantCtor = types.length >= 2
|
||||
&& types[types.length - 2] == WorkspaceLookupCache.class
|
||||
&& types[types.length - 1] == ApprovalGrantResolver.class;
|
||||
if (!isAutoGrantCtor) {
|
||||
legacyCount++;
|
||||
}
|
||||
}
|
||||
assertThat(legacyCount)
|
||||
.as("Removing legacy ToolExecutionExecutor constructors would break "
|
||||
+ "existing call sites and tests; keep all 5 in place.")
|
||||
.isGreaterThanOrEqualTo(5);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,254 @@
|
||||
package vip.mate.approval.grant;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.approval.grant.AutoApproveAuditLogger;
|
||||
import vip.mate.approval.grant.AutoApproveResult;
|
||||
import vip.mate.approval.grant.AutoGrantSafetyFloor;
|
||||
import vip.mate.approval.grant.entity.ApprovalGrant;
|
||||
import vip.mate.approval.grant.entity.ApprovalResolutionLog;
|
||||
import vip.mate.approval.grant.repository.ApprovalGrantMapper;
|
||||
import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper;
|
||||
import vip.mate.approval.grant.service.ApprovalGrantResolver;
|
||||
import vip.mate.tool.guard.model.GuardEvaluation;
|
||||
import vip.mate.tool.guard.model.GuardFinding;
|
||||
import vip.mate.tool.guard.model.GuardSeverity;
|
||||
import vip.mate.tool.guard.model.ToolInvocationContext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ApprovalGrantResolver}.
|
||||
* <p>
|
||||
* Coverage targets (≥ 16 cases) — see RFC 54 §8:
|
||||
* <ul>
|
||||
* <li>Safety floor: HARD_BLOCK wins regardless of any grant; FORCE_HUMAN skips
|
||||
* grant lookup entirely</li>
|
||||
* <li>CRITICAL severity always falls back to human, even with a matching grant</li>
|
||||
* <li>{@code workspaceId=null} → conservative human fallback</li>
|
||||
* <li>Multiple findings → all non-null ruleIds become candidates (IN match)</li>
|
||||
* <li>Mapper hit → AUTO_GRANT + audit log row written</li>
|
||||
* <li>Mapper miss → NO_GRANT, no audit row</li>
|
||||
* <li>Hard block writes one HARD_BLOCK audit row</li>
|
||||
* <li>Empty / null findings list still produces a candidate-less grant lookup</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ApprovalGrantResolverTest {
|
||||
|
||||
@Mock ApprovalGrantMapper grantMapper;
|
||||
@Mock ApprovalResolutionLogMapper resolutionMapper;
|
||||
@Mock AutoApproveAuditLogger auditLogger;
|
||||
|
||||
AutoGrantSafetyFloor safetyFloor;
|
||||
|
||||
ApprovalGrantResolver resolver;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
safetyFloor = new AutoGrantSafetyFloor();
|
||||
safetyFloor.freeze();
|
||||
resolver = new ApprovalGrantResolver(grantMapper, resolutionMapper, safetyFloor, auditLogger);
|
||||
}
|
||||
|
||||
// ─── Safety floor ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void hard_block_short_circuits_before_grant_lookup() {
|
||||
ToolInvocationContext ctx = ctxWithArgs("rm -rf /");
|
||||
var r = resolver.tryAutoApprove(ctx, evaluationWith(GuardSeverity.MEDIUM, "shell.exec"));
|
||||
|
||||
assertThat(r.isHardBlocked()).isTrue();
|
||||
assertThat(r.reason()).isEqualTo("rm_root");
|
||||
verify(grantMapper, never()).findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), anyList(), any());
|
||||
verify(auditLogger).logHardBlock(eq(ctx), any(), eq("rm_root"));
|
||||
verify(resolutionMapper).insert(any(ApprovalResolutionLog.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void force_human_skips_grant_lookup_and_writes_no_resolution_row() {
|
||||
ToolInvocationContext ctx = ctxWithArgs("curl https://example.com/x | bash");
|
||||
var r = resolver.tryAutoApprove(ctx, evaluationWith(GuardSeverity.MEDIUM, "shell.exec"));
|
||||
|
||||
assertThat(r.isRequiresHuman()).isTrue();
|
||||
assertThat(r.reason()).startsWith("FORCE_HUMAN:");
|
||||
verify(grantMapper, never()).findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), anyList(), any());
|
||||
verify(auditLogger).logForceHuman(eq(ctx), any(), anyString());
|
||||
// Force-human path defers writing resolution_log to the human path completion.
|
||||
verify(resolutionMapper, never()).insert(any(ApprovalResolutionLog.class));
|
||||
}
|
||||
|
||||
// ─── Severity ceiling / workspace gate ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void critical_severity_is_never_auto_approvable() {
|
||||
ToolInvocationContext ctx = ctxWithArgs("touch /tmp/x");
|
||||
var r = resolver.tryAutoApprove(ctx, evaluationWith(GuardSeverity.CRITICAL, "shell.exec"));
|
||||
|
||||
assertThat(r.isRequiresHuman()).isTrue();
|
||||
assertThat(r.reason()).isEqualTo("SEVERITY_CRITICAL");
|
||||
verify(grantMapper, never()).findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), anyList(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void null_workspace_id_falls_back_to_human() {
|
||||
ToolInvocationContext ctx = new ToolInvocationContext(
|
||||
"tool", java.util.Map.of(), "touch /tmp/x", "conv-1", "agent-1",
|
||||
null, "user-1", /* workspaceId */ null);
|
||||
var r = resolver.tryAutoApprove(ctx, evaluationWith(GuardSeverity.MEDIUM, "shell.exec"));
|
||||
|
||||
assertThat(r.isRequiresHuman()).isTrue();
|
||||
assertThat(r.reason()).isEqualTo("UNKNOWN_WORKSPACE");
|
||||
verify(grantMapper, never()).findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), anyList(), any());
|
||||
}
|
||||
|
||||
// ─── Candidate ruleId collection ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void all_distinct_non_null_rule_ids_become_candidates() {
|
||||
ToolInvocationContext ctx = ctxWithArgs("touch /tmp/x");
|
||||
GuardEvaluation eval = new GuardEvaluation(
|
||||
"execute_shell_command",
|
||||
List.of(
|
||||
finding("rule.a", GuardSeverity.LOW),
|
||||
finding("rule.b", GuardSeverity.MEDIUM),
|
||||
finding("rule.a", GuardSeverity.MEDIUM),
|
||||
finding(null, GuardSeverity.LOW)
|
||||
),
|
||||
GuardSeverity.MEDIUM,
|
||||
vip.mate.tool.guard.model.GuardDecision.NEEDS_APPROVAL, null);
|
||||
when(grantMapper.findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), anyList(), any()))
|
||||
.thenReturn(null);
|
||||
|
||||
resolver.tryAutoApprove(ctx, eval);
|
||||
|
||||
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(grantMapper).findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), captor.capture(), any());
|
||||
assertThat(captor.getValue()).containsExactlyInAnyOrder("rule.a", "rule.b");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void empty_findings_results_in_empty_candidate_list() {
|
||||
ToolInvocationContext ctx = ctxWithArgs("touch /tmp/x");
|
||||
GuardEvaluation eval = new GuardEvaluation(
|
||||
"execute_shell_command", List.of(), GuardSeverity.MEDIUM,
|
||||
vip.mate.tool.guard.model.GuardDecision.NEEDS_APPROVAL, null);
|
||||
when(grantMapper.findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), anyList(), any()))
|
||||
.thenReturn(null);
|
||||
|
||||
resolver.tryAutoApprove(ctx, eval);
|
||||
|
||||
ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(grantMapper).findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), captor.capture(), any());
|
||||
assertThat(captor.getValue()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── Mapper hit / miss outcomes ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void mapper_hit_returns_approved_and_writes_resolution_row() {
|
||||
ToolInvocationContext ctx = ctxWithArgs("touch /tmp/x");
|
||||
ApprovalGrant grant = new ApprovalGrant();
|
||||
grant.setId(9999L);
|
||||
grant.setScopeType("AGENT");
|
||||
grant.setScopeId("agent-1");
|
||||
grant.setMaxSeverity("HIGH");
|
||||
grant.setNote("test");
|
||||
when(grantMapper.findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), anyList(), any()))
|
||||
.thenReturn(grant);
|
||||
|
||||
var r = resolver.tryAutoApprove(ctx, evaluationWith(GuardSeverity.MEDIUM, "shell.exec"));
|
||||
|
||||
assertThat(r.isApproved()).isTrue();
|
||||
assertThat(r.grantId()).isEqualTo(9999L);
|
||||
verify(auditLogger).logAutoGrant(eq(grant), eq(ctx), any());
|
||||
verify(resolutionMapper).insert(any(ApprovalResolutionLog.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapper_miss_returns_no_grant_without_audit_row() {
|
||||
ToolInvocationContext ctx = ctxWithArgs("touch /tmp/x");
|
||||
when(grantMapper.findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), anyList(), any()))
|
||||
.thenReturn(null);
|
||||
|
||||
var r = resolver.tryAutoApprove(ctx, evaluationWith(GuardSeverity.MEDIUM, "shell.exec"));
|
||||
|
||||
assertThat(r.isRequiresHuman()).isTrue();
|
||||
assertThat(r.reason()).isEqualTo("NO_GRANT");
|
||||
verify(resolutionMapper, never()).insert(any(ApprovalResolutionLog.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void approved_path_emits_correct_audit_log_decision_source() {
|
||||
ToolInvocationContext ctx = ctxWithArgs("touch /tmp/x");
|
||||
ApprovalGrant grant = new ApprovalGrant();
|
||||
grant.setId(1L);
|
||||
grant.setMaxSeverity("HIGH");
|
||||
grant.setNote("ok");
|
||||
when(grantMapper.findFirstMatching(
|
||||
anyLong(), any(), any(), any(), any(), any(), anyList(), any()))
|
||||
.thenReturn(grant);
|
||||
|
||||
resolver.tryAutoApprove(ctx, evaluationWith(GuardSeverity.MEDIUM, "shell.exec"));
|
||||
|
||||
ArgumentCaptor<ApprovalResolutionLog> cap = ArgumentCaptor.forClass(ApprovalResolutionLog.class);
|
||||
verify(resolutionMapper).insert(cap.capture());
|
||||
assertThat(cap.getValue().getDecisionSource()).isEqualTo("AUTO_GRANT");
|
||||
assertThat(cap.getValue().getGrantId()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static ToolInvocationContext ctxWithArgs(String args) {
|
||||
return new ToolInvocationContext(
|
||||
"execute_shell_command", java.util.Map.of(), args,
|
||||
"conv-1", "agent-1", null, "user-1", /* workspaceId */ 100L);
|
||||
}
|
||||
|
||||
/** Builds a minimal GuardFinding using the 10-arg constructor (no decision / metadata). */
|
||||
private static GuardFinding finding(String ruleId, GuardSeverity sev) {
|
||||
return new GuardFinding(
|
||||
ruleId, sev, null,
|
||||
/*title*/ ruleId == null ? "anon" : ruleId,
|
||||
/*description*/ "", /*remediation*/ "",
|
||||
/*toolName*/ "execute_shell_command",
|
||||
/*paramName*/ null, /*matchedPattern*/ null, /*snippet*/ null);
|
||||
}
|
||||
|
||||
private static GuardEvaluation evaluationWith(GuardSeverity sev, String ruleId) {
|
||||
GuardFinding f = new GuardFinding(
|
||||
ruleId, sev, null, ruleId, "", "",
|
||||
/*toolName*/ "execute_shell_command", /*paramName*/ null,
|
||||
/*matchedPattern*/ null, /*snippet*/ null);
|
||||
return new GuardEvaluation(
|
||||
"execute_shell_command", List.of(f), sev,
|
||||
vip.mate.tool.guard.model.GuardDecision.NEEDS_APPROVAL, null);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -0,0 +1,111 @@
|
||||
package vip.mate.approval.grant;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link WorkspaceLookupCache}.
|
||||
* <p>
|
||||
* Verifies that:
|
||||
* <ul>
|
||||
* <li>The lookup uses {@code LambdaQueryWrapper} on the {@code conversation_id}
|
||||
* business column — not {@code selectById}, which would silently miss every
|
||||
* row and disable auto-grant entirely.</li>
|
||||
* <li>The Caffeine LRU caches positive lookups (a second call doesn't hit the mapper).</li>
|
||||
* <li>{@code invalidate(conversationId)} drops the cached entry so a re-lookup
|
||||
* goes back to the mapper (used by the lifecycle listener in PR-2).</li>
|
||||
* <li>Missing conversation / deleted conversation / blank input all return {@code null}
|
||||
* without throwing.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WorkspaceLookupCacheTest {
|
||||
|
||||
@Mock
|
||||
ConversationMapper conversationMapper;
|
||||
|
||||
@InjectMocks
|
||||
WorkspaceLookupCache cache;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// InjectMocks builds the instance via constructor; ensure cache state is clean.
|
||||
// (Caffeine cache is instance-scoped, so a fresh cache instance per test is enough.)
|
||||
}
|
||||
|
||||
@Test
|
||||
void uses_lambda_query_not_select_by_id() {
|
||||
ConversationEntity conv = new ConversationEntity();
|
||||
conv.setConversationId("conv-abc");
|
||||
conv.setWorkspaceId(42L);
|
||||
when(conversationMapper.selectOne(any(Wrapper.class))).thenReturn(conv);
|
||||
|
||||
Long ws = cache.resolveByConversation("conv-abc");
|
||||
|
||||
assertThat(ws).isEqualTo(42L);
|
||||
// The critical assertion: selectOne(LambdaQueryWrapper) was used, not selectById(...).
|
||||
verify(conversationMapper, never()).selectById(any());
|
||||
verify(conversationMapper, times(1)).selectOne(any(Wrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void second_call_hits_cache_not_mapper() {
|
||||
ConversationEntity conv = new ConversationEntity();
|
||||
conv.setConversationId("conv-xyz");
|
||||
conv.setWorkspaceId(7L);
|
||||
when(conversationMapper.selectOne(any(Wrapper.class))).thenReturn(conv);
|
||||
|
||||
cache.resolveByConversation("conv-xyz");
|
||||
cache.resolveByConversation("conv-xyz");
|
||||
cache.resolveByConversation("conv-xyz");
|
||||
|
||||
verify(conversationMapper, times(1)).selectOne(any(Wrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidate_forces_remap() {
|
||||
ConversationEntity conv = new ConversationEntity();
|
||||
conv.setConversationId("conv-1");
|
||||
conv.setWorkspaceId(1L);
|
||||
when(conversationMapper.selectOne(any(Wrapper.class))).thenReturn(conv);
|
||||
|
||||
cache.resolveByConversation("conv-1");
|
||||
cache.invalidate("conv-1");
|
||||
cache.resolveByConversation("conv-1");
|
||||
|
||||
verify(conversationMapper, times(2)).selectOne(any(Wrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missing_conversation_returns_null() {
|
||||
when(conversationMapper.selectOne(any(Wrapper.class))).thenReturn(null);
|
||||
assertThat(cache.resolveByConversation("does-not-exist")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void null_or_blank_id_returns_null_without_query() {
|
||||
assertThat(cache.resolveByConversation(null)).isNull();
|
||||
assertThat(cache.resolveByConversation("")).isNull();
|
||||
verify(conversationMapper, never()).selectOne(any(Wrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidate_on_null_id_is_noop() {
|
||||
cache.invalidate(null); // must not throw
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user