feat(agent): enforce tool policy

This commit is contained in:
2026-07-24 20:12:43 +08:00
parent ac6b14926a
commit b4cc83168d
2 changed files with 142 additions and 0 deletions
@@ -0,0 +1,49 @@
package org.dromara.aihr.agent;
import org.dromara.aihr.agent.AihrAgentDto.AgentPlan;
import org.dromara.aihr.agent.AihrAgentDto.Intent;
import org.dromara.aihr.agent.AihrAgentDto.Tool;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.stereotype.Service;
import java.util.Locale;
@Service
public class AihrAgentPolicy {
public void authorize(AihrKnowledgePrincipal principal, AgentPlan plan, boolean externalConsent) {
if (principal == null || principal.userId() == null) {
throw new ServiceException("登录身份缺失,请重新登录", 401);
}
if (plan == null || plan.intent() == null || plan.tool() == null) {
throw new ServiceException("Agent 工具计划无效", 403);
}
if ((plan.intent() == Intent.CAPTURE_FACT || plan.intent() == Intent.DRAFT_ACTION)
!= plan.requiresConfirmation()) {
throw new ServiceException("写入类操作必须先确认", 403);
}
if ((plan.tool() == Tool.WEB_RESEARCH) != plan.requiresExternalConsent()
|| plan.requiresExternalConsent() && !externalConsent) {
throw new ServiceException("全网查询需要用户明确同意", 403);
}
boolean employee = hasRole(principal, "employee");
boolean supervisor = hasRole(principal, "supervisor");
boolean allowed = switch (plan.tool()) {
case NONE -> plan.intent() != Intent.DRAFT_ACTION || employee || supervisor;
case KNOWLEDGE_SEARCH, KNOWLEDGE_RESOURCE, MY_CURRENT_TASKS, MY_PRACTICE_SUMMARY,
CAPTURE_MEMORY_DRAFT, MEDIA_ANALYZE, WEB_RESEARCH -> employee || supervisor;
case TEAM_PRACTICE_SUMMARY -> supervisor;
};
if (!allowed) {
throw new ServiceException("当前身份无权使用该 Agent 工具", 403);
}
}
private static boolean hasRole(AihrKnowledgePrincipal principal, String role) {
return principal.roles().stream()
.map(value -> value == null ? "" : value.trim().toLowerCase(Locale.ROOT))
.anyMatch(role::equals);
}
}
@@ -0,0 +1,93 @@
package org.dromara.aihr.agent;
import org.dromara.aihr.agent.AihrAgentDto.AgentPlan;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.CAPTURE_FACT;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.LIVE_MY_WORK;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.LIVE_TEAM_WORK;
import static org.dromara.aihr.agent.AihrAgentDto.Intent.WEB_RESEARCH;
import static org.dromara.aihr.agent.AihrAgentDto.ResponseStyle.DRAFT;
import static org.dromara.aihr.agent.AihrAgentDto.ResponseStyle.FACT;
import static org.dromara.aihr.agent.AihrAgentDto.ResponseStyle.WEB;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.CAPTURE_MEMORY_DRAFT;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.MY_CURRENT_TASKS;
import static org.dromara.aihr.agent.AihrAgentDto.Tool.TEAM_PRACTICE_SUMMARY;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Tag("dev")
class AihrAgentPolicyTest {
private final AihrAgentPolicy policy = new AihrAgentPolicy();
@Test
void employeeCanReadOwnTasksButCannotReadTeamSummary() {
assertDoesNotThrow(() -> policy.authorize(employee(), plan(LIVE_MY_WORK, MY_CURRENT_TASKS, false, false), false));
assertThrows(ServiceException.class,
() -> policy.authorize(employee(), plan(LIVE_TEAM_WORK, TEAM_PRACTICE_SUMMARY, false, false), false));
}
@Test
void supervisorCanReadTeamSummary() {
assertDoesNotThrow(() ->
policy.authorize(supervisor(), plan(LIVE_TEAM_WORK, TEAM_PRACTICE_SUMMARY, false, false), false));
}
@Test
void candidateCannotReadEmployeeTasks() {
assertThrows(ServiceException.class,
() -> policy.authorize(candidate(), plan(LIVE_MY_WORK, MY_CURRENT_TASKS, false, false), false));
}
@Test
void writeLikePlanMustRemainConfirmationGated() {
assertDoesNotThrow(() ->
policy.authorize(employee(), plan(CAPTURE_FACT, CAPTURE_MEMORY_DRAFT, true, false), false));
assertThrows(ServiceException.class,
() -> policy.authorize(employee(), plan(CAPTURE_FACT, CAPTURE_MEMORY_DRAFT, false, false), false));
}
@Test
void externalToolRequiresExplicitRequestConsent() {
AgentPlan web = new AgentPlan(WEB_RESEARCH, "查询公开信息", AihrAgentDto.Tool.WEB_RESEARCH,
false, true, WEB);
assertThrows(ServiceException.class, () -> policy.authorize(employee(), web, false));
assertDoesNotThrow(() -> policy.authorize(employee(), web, true));
}
@Test
void missingOrUnsupportedToolIsRejected() {
AgentPlan forged = new AgentPlan(LIVE_MY_WORK, "绕过工具注册", null, false, false, FACT);
assertThrows(ServiceException.class, () -> policy.authorize(employee(), forged, false));
}
private static AgentPlan plan(AihrAgentDto.Intent intent, AihrAgentDto.Tool tool,
boolean confirmation, boolean externalConsent) {
return new AgentPlan(intent, "test", tool, confirmation, externalConsent,
confirmation ? DRAFT : FACT);
}
private static AihrKnowledgePrincipal employee() {
return principal(Set.of("employee"));
}
private static AihrKnowledgePrincipal supervisor() {
return principal(Set.of("employee", "supervisor"));
}
private static AihrKnowledgePrincipal candidate() {
return principal(Set.of("candidate"));
}
private static AihrKnowledgePrincipal principal(Set<String> roles) {
return new AihrKnowledgePrincipal("000000", 1L, "app_user", "person-1", roles, Set.of("P1"), "app");
}
}