feat(approval): grant-based auto-approve with safety floor and resolution log

This commit is contained in:
matevip 2026-05-27 14:07:39 +08:00
parent 38dce5c20a
commit b7e923fac4
22 changed files with 1478 additions and 15 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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();
}
}

View File

@ -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; }
}

View File

@ -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 conversationworkspace 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();
}
}

View File

@ -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() {}
}
}

View File

@ -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() {}
}
}

View File

@ -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 &gt; AGENT &gt; USER &gt; 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);
}

View File

@ -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> {
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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 &gt; CURRENT_TIMESTAMP)
AND
CASE max_severity
WHEN 'HIGH' THEN 3
WHEN 'MEDIUM' THEN 2
WHEN 'LOW' THEN 1
ELSE 0
END
&gt;=
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>

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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
}
}