feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -31,6 +31,7 @@ import tech.easyflow.approval.mapper.ApprovalTaskMapper;
|
||||
import tech.easyflow.approval.service.ApprovalActionFacade;
|
||||
import tech.easyflow.approval.service.ApprovalAssigneeService;
|
||||
import tech.easyflow.approval.service.ApprovalQueryService;
|
||||
import tech.easyflow.approval.support.ApprovalSnapshotProjection;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
@@ -171,7 +172,8 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
|
||||
detail.setApplicantId(instance.getApplicantId());
|
||||
detail.setSubmittedAt(instance.getSubmittedAt());
|
||||
detail.setFinishedAt(instance.getFinishedAt());
|
||||
detail.setSnapshotJson(instance.getSnapshotJson());
|
||||
detail.setSnapshotJson(ApprovalSnapshotProjection.project(
|
||||
instance.getResourceType(), instance.getSnapshotJson()));
|
||||
|
||||
List<ApprovalLog> logs = approvalLogMapper.selectListByQuery(
|
||||
QueryWrapper.create().eq(ApprovalLog::getInstanceId, instanceId));
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package tech.easyflow.approval.support;
|
||||
|
||||
import java.lang.reflect.Array;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 审批详情快照的响应投影。
|
||||
*
|
||||
* <p>审批实例仍持久化完整冻结快照,详情响应仅投影审核需要的摘要字段,避免内部运行正文、
|
||||
* 资源快照和连接凭据通过管理端查询接口泄露。</p>
|
||||
*/
|
||||
public final class ApprovalSnapshotProjection {
|
||||
|
||||
private static final Set<String> STRICT_RESOURCE_TYPES = Set.of("AGENT", "SKILL");
|
||||
private static final Set<String> REMOVED_KEYS = Set.of(
|
||||
"skillcontent",
|
||||
"content",
|
||||
"contentref",
|
||||
"resources",
|
||||
"resourcecontent",
|
||||
"publishedsnapshotjson",
|
||||
"publishedtoolbindingsjson",
|
||||
"prompt",
|
||||
"systemprompt",
|
||||
"instructions",
|
||||
"instruction",
|
||||
"script",
|
||||
"source",
|
||||
"code",
|
||||
"body",
|
||||
"text",
|
||||
"raw",
|
||||
"payload",
|
||||
"tools",
|
||||
"mcptoolmanifest",
|
||||
"manifest",
|
||||
"headers",
|
||||
"header",
|
||||
"environment",
|
||||
"env",
|
||||
"command",
|
||||
"args",
|
||||
"arguments",
|
||||
"query",
|
||||
"endpoint",
|
||||
"url",
|
||||
"uri",
|
||||
"credentials",
|
||||
"credential",
|
||||
"secret",
|
||||
"password",
|
||||
"token",
|
||||
"apikey",
|
||||
"privatekey",
|
||||
"inputschema",
|
||||
"outputschema",
|
||||
"schema");
|
||||
|
||||
private ApprovalSnapshotProjection() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将审批冻结快照转换为可下发的响应副本。
|
||||
*
|
||||
* @param resourceType 审批资源类型
|
||||
* @param snapshot 完整冻结快照
|
||||
* @return 不包含内部正文与连接配置的响应快照;原快照为空时返回空 Map
|
||||
*/
|
||||
public static Map<String, Object> project(String resourceType, Map<String, Object> snapshot) {
|
||||
if (snapshot == null || snapshot.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
if (!STRICT_RESOURCE_TYPES.contains(normalizeResourceType(resourceType))) {
|
||||
return copyMap(snapshot);
|
||||
}
|
||||
Map<String, Object> projected = new LinkedHashMap<>();
|
||||
snapshot.forEach((key, value) -> {
|
||||
if ("resourceSnapshot".equals(key) && value instanceof Map<?, ?> resourceSnapshot) {
|
||||
projected.put(key, projectMap(resourceSnapshot));
|
||||
return;
|
||||
}
|
||||
projected.put(key, copyValue(value));
|
||||
});
|
||||
return projected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归投影 L21 资源快照。
|
||||
*
|
||||
* @param source 待投影 Map
|
||||
* @return 保持原有顺序的安全摘要 Map
|
||||
*/
|
||||
private static Map<String, Object> projectMap(Map<?, ?> source) {
|
||||
Map<String, Object> projected = new LinkedHashMap<>();
|
||||
source.forEach((rawKey, value) -> {
|
||||
String key = String.valueOf(rawKey);
|
||||
String normalizedKey = normalizeKey(key);
|
||||
if ("resources".equals(normalizedKey)) {
|
||||
putCollectionCount(projected, "resourceCount", value);
|
||||
return;
|
||||
}
|
||||
if ("mcptoolmanifest".equals(normalizedKey) || "manifest".equals(normalizedKey)) {
|
||||
putCollectionCount(projected, "manifestToolCount", value);
|
||||
return;
|
||||
}
|
||||
if ("resourcesnapshot".equals(normalizedKey)) {
|
||||
return;
|
||||
}
|
||||
if (isRemovedKey(normalizedKey)) {
|
||||
return;
|
||||
}
|
||||
if (isConfigKey(normalizedKey)) {
|
||||
return;
|
||||
}
|
||||
projected.put(key, projectValue(value));
|
||||
});
|
||||
return projected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归投影 Map、集合和数组值。
|
||||
*
|
||||
* @param value 原始值
|
||||
* @return 安全副本
|
||||
*/
|
||||
private static Object projectValue(Object value) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return projectMap(map);
|
||||
}
|
||||
if (value instanceof Collection<?> collection) {
|
||||
List<Object> projected = new ArrayList<>(collection.size());
|
||||
collection.forEach(item -> projected.add(projectValue(item)));
|
||||
return projected;
|
||||
}
|
||||
if (value != null && value.getClass().isArray()) {
|
||||
int length = Array.getLength(value);
|
||||
List<Object> projected = new ArrayList<>(length);
|
||||
for (int index = 0; index < length; index++) {
|
||||
projected.add(projectValue(Array.get(value, index)));
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段是否属于连接配置或其他配置正文。
|
||||
*
|
||||
* @param normalizedKey 已规范化字段名
|
||||
* @return 需要移除时为 true
|
||||
*/
|
||||
private static boolean isConfigKey(String normalizedKey) {
|
||||
return normalizedKey.endsWith("config") || normalizedKey.endsWith("configjson")
|
||||
|| normalizedKey.endsWith("configuration");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段是否需要从严格响应投影中移除。
|
||||
*
|
||||
* @param normalizedKey 已规范化字段名
|
||||
* @return 需要移除时为 true
|
||||
*/
|
||||
private static boolean isRemovedKey(String normalizedKey) {
|
||||
if (normalizedKey.endsWith("hash") || normalizedKey.endsWith("count")) {
|
||||
return false;
|
||||
}
|
||||
return REMOVED_KEYS.contains(normalizedKey)
|
||||
|| normalizedKey.endsWith("content")
|
||||
|| normalizedKey.endsWith("contentref")
|
||||
|| normalizedKey.endsWith("storagepath")
|
||||
|| normalizedKey.endsWith("physicalpath")
|
||||
|| normalizedKey.endsWith("source")
|
||||
|| normalizedKey.contains("manifest")
|
||||
|| normalizedKey.contains("credential")
|
||||
|| normalizedKey.endsWith("secret")
|
||||
|| normalizedKey.endsWith("password")
|
||||
|| normalizedKey.endsWith("token")
|
||||
|| normalizedKey.endsWith("apikey")
|
||||
|| normalizedKey.endsWith("privatekey")
|
||||
|| normalizedKey.endsWith("schema")
|
||||
|| normalizedKey.endsWith("headers")
|
||||
|| normalizedKey.endsWith("environment")
|
||||
|| normalizedKey.endsWith("env")
|
||||
|| normalizedKey.endsWith("command")
|
||||
|| normalizedKey.endsWith("args")
|
||||
|| normalizedKey.endsWith("arguments")
|
||||
|| normalizedKey.endsWith("query")
|
||||
|| normalizedKey.endsWith("queryparams")
|
||||
|| normalizedKey.endsWith("queryparameters")
|
||||
|| normalizedKey.endsWith("endpoint")
|
||||
|| normalizedKey.endsWith("url")
|
||||
|| normalizedKey.endsWith("uri");
|
||||
}
|
||||
|
||||
/**
|
||||
* 在源值可计数时写入摘要数量,并避免覆盖已存在的显式数量。
|
||||
*
|
||||
* @param projected 目标 Map
|
||||
* @param countKey 数量字段名
|
||||
* @param value 待计数值
|
||||
*/
|
||||
private static void putCollectionCount(Map<String, Object> projected, String countKey, Object value) {
|
||||
if (projected.containsKey(countKey)) {
|
||||
return;
|
||||
}
|
||||
if (value instanceof Collection<?> collection) {
|
||||
projected.put(countKey, collection.size());
|
||||
} else if (value != null && value.getClass().isArray()) {
|
||||
projected.put(countKey, Array.getLength(value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为不采用严格 L21 投影的历史资源复制快照,避免响应方修改持久化 Map。
|
||||
*
|
||||
* @param source 原始 Map
|
||||
* @return 深复制 Map
|
||||
*/
|
||||
private static Map<String, Object> copyMap(Map<?, ?> source) {
|
||||
Map<String, Object> copy = new LinkedHashMap<>();
|
||||
source.forEach((key, value) -> copy.put(String.valueOf(key), copyValue(value)));
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深复制 Map、集合和数组。
|
||||
*
|
||||
* @param value 原始值
|
||||
* @return 深复制值
|
||||
*/
|
||||
private static Object copyValue(Object value) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return copyMap(map);
|
||||
}
|
||||
if (value instanceof Collection<?> collection) {
|
||||
List<Object> copy = new ArrayList<>(collection.size());
|
||||
collection.forEach(item -> copy.add(copyValue(item)));
|
||||
return copy;
|
||||
}
|
||||
if (value != null && value.getClass().isArray()) {
|
||||
int length = Array.getLength(value);
|
||||
List<Object> copy = new ArrayList<>(length);
|
||||
for (int index = 0; index < length; index++) {
|
||||
copy.add(copyValue(Array.get(value, index)));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化资源类型。
|
||||
*
|
||||
* @param resourceType 原始资源类型
|
||||
* @return 大写资源类型
|
||||
*/
|
||||
private static String normalizeResourceType(String resourceType) {
|
||||
return resourceType == null ? "" : resourceType.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化字段名,忽略大小写和分隔符。
|
||||
*
|
||||
* @param key 原始字段名
|
||||
* @return 仅含小写字母和数字的字段名
|
||||
*/
|
||||
private static String normalizeKey(String key) {
|
||||
return key == null ? "" : key.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -136,7 +136,9 @@ public class ApprovalQueryServiceImplAccessTest {
|
||||
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
|
||||
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
|
||||
ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID);
|
||||
assertSame(snapshot, detail.getSnapshotJson());
|
||||
assertNotSame(snapshot, detail.getSnapshotJson());
|
||||
assertEquals(Map.of(), detail.getSnapshotJson().get("resourceSnapshot"));
|
||||
assertTrue(detail.getSnapshotJson().containsKey("steps"));
|
||||
assertFalse(detail.isCanApprove());
|
||||
assertFalse(detail.isCanReject());
|
||||
assertTrue(detail.isCanRevoke());
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package tech.easyflow.approval.support;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link ApprovalSnapshotProjection} 响应脱敏契约测试。
|
||||
*/
|
||||
public class ApprovalSnapshotProjectionTest {
|
||||
|
||||
/**
|
||||
* 验证 Skill 审批仅保留标准包和 Tool 绑定摘要,不返回正文、资源正文或 MCP 连接信息。
|
||||
*/
|
||||
@Test
|
||||
public void shouldProjectSkillSnapshotToSafeSummary() {
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("previousStatus", "DRAFT");
|
||||
snapshot.put("resourceSnapshot", Map.of(
|
||||
"schemaVersion", 2,
|
||||
"name", "contract-review",
|
||||
"description", "合同审查",
|
||||
"skillContent", "# private instructions",
|
||||
"resources", List.of(Map.of(
|
||||
"path", "references/private.md",
|
||||
"content", "private reference",
|
||||
"contentRef", "internal-ref")),
|
||||
"packageHash", "package-hash",
|
||||
"contentSnapshotHash", "content-hash",
|
||||
"platformToolBindings", Map.of(
|
||||
"bindings", List.of(Map.of(
|
||||
"toolType", "MCP",
|
||||
"targetId", 9,
|
||||
"displayName", "GitHub",
|
||||
"toolCount", 2,
|
||||
"mcpToolManifestHash", "manifest-hash",
|
||||
"resourceSummary", Map.of(
|
||||
"title", "GitHub",
|
||||
"baseUrl", "https://internal.example?token=secret",
|
||||
"requestHeaders", Map.of("Authorization", "secret")),
|
||||
"mcpToolManifest", List.of(Map.of(
|
||||
"name", "search",
|
||||
"inputSchema", Map.of("token", "secret"))),
|
||||
"resourceSnapshot", Map.of(
|
||||
"configJson", "{\"env\":{\"TOKEN\":\"secret\"}}",
|
||||
"headers", Map.of("Authorization", "secret"),
|
||||
"environment", Map.of("TOKEN", "secret")))),
|
||||
"snapshotHash", "tools-hash"),
|
||||
"toolBindingsHash", "tools-hash",
|
||||
"snapshotHash", "aggregate-hash"));
|
||||
|
||||
Map<String, Object> projected = ApprovalSnapshotProjection.project("SKILL", snapshot);
|
||||
Map<?, ?> resource = (Map<?, ?>) projected.get("resourceSnapshot");
|
||||
Map<?, ?> platformBindings = (Map<?, ?>) resource.get("platformToolBindings");
|
||||
Map<?, ?> binding = (Map<?, ?>) ((List<?>) platformBindings.get("bindings")).get(0);
|
||||
|
||||
assertEquals("contract-review", resource.get("name"));
|
||||
assertEquals("合同审查", resource.get("description"));
|
||||
assertEquals("aggregate-hash", resource.get("snapshotHash"));
|
||||
assertEquals(1, resource.get("resourceCount"));
|
||||
assertEquals("manifest-hash", binding.get("mcpToolManifestHash"));
|
||||
assertEquals(2, binding.get("toolCount"));
|
||||
assertEquals(1, binding.get("manifestToolCount"));
|
||||
assertFalse(resource.containsKey("skillContent"));
|
||||
assertFalse(resource.containsKey("resources"));
|
||||
assertFalse(binding.containsKey("mcpToolManifest"));
|
||||
assertFalse(binding.containsKey("resourceSnapshot"));
|
||||
Map<?, ?> bindingSummary = (Map<?, ?>) binding.get("resourceSummary");
|
||||
assertEquals("GitHub", bindingSummary.get("title"));
|
||||
assertFalse(bindingSummary.containsKey("baseUrl"));
|
||||
assertFalse(bindingSummary.containsKey("requestHeaders"));
|
||||
assertTrue(serialize(projected).indexOf("secret") < 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Agent 审批保留绑定摘要与 hash,同时递归移除配置正文和嵌套 Skill 运行快照。
|
||||
*/
|
||||
@Test
|
||||
public void shouldProjectAgentSnapshotToSafeSummary() {
|
||||
Map<String, Object> resourceSnapshot = new LinkedHashMap<>();
|
||||
resourceSnapshot.put("id", 7);
|
||||
resourceSnapshot.put("name", "审查 Agent");
|
||||
resourceSnapshot.put("description", "用于审查合同");
|
||||
resourceSnapshot.put("promptConfigJson", Map.of("systemPrompt", "private prompt"));
|
||||
resourceSnapshot.put("modelConfigJson", Map.of("apiKey", "secret"));
|
||||
resourceSnapshot.put("basicSummary", Map.of("name", "审查 Agent", "status", 1));
|
||||
resourceSnapshot.put("skillBindings", List.of(Map.of(
|
||||
"skillId", 21,
|
||||
"sortNo", 0,
|
||||
"resourceSummary", Map.of(
|
||||
"displayName", "合同审查",
|
||||
"snapshotHash", "skill-hash",
|
||||
"textResourceCount", 3),
|
||||
"resourceSnapshot", Map.of(
|
||||
"skillContent", "private skill",
|
||||
"resources", List.of(Map.of("content", "private reference")),
|
||||
"source", "easyflow://internal"))));
|
||||
|
||||
Map<String, Object> projected = ApprovalSnapshotProjection.project(
|
||||
"agent", Map.of("resourceSnapshot", resourceSnapshot));
|
||||
Map<?, ?> resource = (Map<?, ?>) projected.get("resourceSnapshot");
|
||||
Map<?, ?> binding = (Map<?, ?>) ((List<?>) resource.get("skillBindings")).get(0);
|
||||
Map<?, ?> summary = (Map<?, ?>) binding.get("resourceSummary");
|
||||
|
||||
assertEquals("审查 Agent", resource.get("name"));
|
||||
assertEquals("合同审查", summary.get("displayName"));
|
||||
assertEquals("skill-hash", summary.get("snapshotHash"));
|
||||
assertEquals(3, summary.get("textResourceCount"));
|
||||
assertFalse(resource.containsKey("promptConfigJson"));
|
||||
assertFalse(resource.containsKey("modelConfigJson"));
|
||||
assertFalse(binding.containsKey("resourceSnapshot"));
|
||||
assertTrue(serialize(projected).indexOf("private skill") < 0);
|
||||
assertTrue(serialize(projected).indexOf("easyflow://internal") < 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证历史审批资源保持兼容,但详情响应使用独立深复制 Map。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepLegacyResourceSnapshotsCompatible() {
|
||||
Map<String, Object> snapshot = Map.of(
|
||||
"resourceSnapshot", Map.of("title", "流程", "content", "workflow source"));
|
||||
|
||||
Map<String, Object> projected = ApprovalSnapshotProjection.project("WORKFLOW", snapshot);
|
||||
|
||||
assertNotSame(snapshot, projected);
|
||||
assertEquals(snapshot, projected);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将测试对象转成稳定字符串,便于断言敏感字面值未出现在任意嵌套层级。
|
||||
*
|
||||
* @param value 待检查对象
|
||||
* @return 对象字符串
|
||||
*/
|
||||
private String serialize(Object value) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user