From fb7bb04f7aeca21725a9075cd0b497463f3b7c78 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Sun, 13 Sep 2026 22:15:13 +0800 Subject: [PATCH] feat: add user-invoked bounded JSON artifact checks --- .../ExecutionEvidenceController.java | 14 ++++ .../ExecutionEvidenceQueryService.java | 21 +++++- .../evidence/service/JsonArtifactRecipe.java | 55 +++++++++++++++ .../tool/document/GeneratedFileCache.java | 36 ++++++++++ .../evidence/ExecutionEvidenceQueryTest.java | 44 ++++++++++++ .../evidence/JsonArtifactRecipeTest.java | 40 +++++++++++ .../GeneratedFileArtifactVersionTest.java | 31 +++++++++ .../api/__tests__/executionEvidence.test.ts | 6 ++ mateclaw-ui/src/api/executionEvidence.ts | 10 +++ .../execution/ExecutionEvidenceList.vue | 69 ++++++++++++++++++- .../__tests__/ExecutionEvidenceList.test.ts | 52 +++++++++++++- mateclaw-ui/src/i18n/locales/en-US.ts | 9 +++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 9 +++ 13 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/execution/evidence/service/JsonArtifactRecipe.java create mode 100644 mateclaw-server/src/test/java/vip/mate/execution/evidence/JsonArtifactRecipeTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/controller/ExecutionEvidenceController.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/controller/ExecutionEvidenceController.java index f0e53053..4ff14b9d 100644 --- a/mateclaw-server/src/main/java/vip/mate/execution/evidence/controller/ExecutionEvidenceController.java +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/controller/ExecutionEvidenceController.java @@ -3,6 +3,10 @@ package vip.mate.execution.evidence.controller; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import java.util.List; +import vip.mate.execution.evidence.service.JsonArtifactRecipe; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -36,4 +40,14 @@ public class ExecutionEvidenceController { @PathVariable Long id) { return R.ok(queries.detail(auth == null ? null : auth.getName(), workspaceId, id)); } + public record JsonCheckRequest(List requiredFields) { } + + @PostMapping("/{id}/json-check") + public R checkJson(Authentication auth, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + @PathVariable Long id, @RequestBody JsonCheckRequest request) { + return R.ok(queries.checkJson(auth == null ? null : auth.getName(), workspaceId, id, + request == null ? null : request.requiredFields())); + } + } diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java index 2ba09bb0..cf748663 100644 --- a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/ExecutionEvidenceQueryService.java @@ -77,11 +77,30 @@ public class ExecutionEvidenceQueryService { } public View detail(String username, Long workspaceId, Long id) { + return authorizedDetail(username, workspaceId, id, true); + } + + private View authorizedDetail(String username, Long workspaceId, Long id, boolean inspectVersion) { if (username == null || username.isBlank() || id == null) throw hidden(); ExecutionEvidence row = store.findById(id).orElseThrow(this::hidden); Long canonicalWorkspace = authorize(username, workspaceId, row.conversationId()); if (!canonicalWorkspace.equals(row.workspaceId())) throw hidden(); - return view(username, row, store.findAttempt(row.attemptId()).orElseThrow(this::hidden), true); + return view(username, row, store.findAttempt(row.attemptId()).orElseThrow(this::hidden), inspectVersion); + } + + public JsonArtifactRecipe.Result checkJson(String username, Long workspaceId, Long id, List fields) { + // Reuse source, attempt and file authorization before any content read. + View view = authorizedDetail(username, workspaceId, id, false); + List required = JsonArtifactRecipe.validate(fields); + if (view.kind() != EvidenceKind.ARTIFACT_SNAPSHOT || view.artifactRef() == null + || "UNAVAILABLE".equals(view.validity())) { + return JsonArtifactRecipe.outcome("UNAVAILABLE", required, List.of()); + } + Long ownerWorkspace = authorize(username, workspaceId, view.conversationId()); + var read = files.readDurableArtifactSnapshot(view.artifactRef(), ownerWorkspace, view.conversationId(), + view.artifactDigest(), properties.getArtifactVersionCheckMaxBytes()); + return "READ".equals(read.status()) ? JsonArtifactRecipe.check(read.bytes(), required) + : JsonArtifactRecipe.outcome(read.status(), required, List.of()); } private Long authorize(String username, Long workspaceId, String conversationId) { diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/JsonArtifactRecipe.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/JsonArtifactRecipe.java new file mode 100644 index 00000000..b89415f3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/JsonArtifactRecipe.java @@ -0,0 +1,55 @@ +package vip.mate.execution.evidence.service; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import vip.mate.exception.MateClawException; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; + +/** Explicit, read-only check of captured bytes; never an acceptance binding. */ +public final class JsonArtifactRecipe { + private static final ObjectMapper JSON = new ObjectMapper(JsonFactory.builder() + .streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(32) + .maxStringLength(1_048_576).maxNameLength(1024).build()) + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build()) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + + private JsonArtifactRecipe() { } + + public record Result(String recipeId, int recipeRevision, String status, List requiredFields, + List missingFields, Instant checkedAt, boolean acceptanceEligible) { } + + public static List validate(List fields) { + if (fields == null || fields.isEmpty() || fields.size() > 16 + || fields.stream().anyMatch(f -> f == null || f.isBlank() || f.length() > 128 + || f.chars().anyMatch(Character::isISOControl)) + || new HashSet<>(fields).size() != fields.size()) { + throw new MateClawException(400, "Specify 1–16 unique top-level JSON fields, each 1–128 characters"); + } + return List.copyOf(fields); + } + + public static Result outcome(String status, List fields, List missing) { + return new Result("json-required-fields", 1, status, List.copyOf(fields), List.copyOf(missing), + Instant.now(), false); + } + + public static Result check(byte[] bytes, List requestedFields) { + List fields = validate(requestedFields); + if (bytes == null || bytes.length > 1_048_576) return outcome("UNKNOWN", fields, List.of()); + try { + var document = JSON.readTree(bytes); + if (document == null || !document.isObject()) return outcome("INVALID_JSON", fields, List.of()); + List missing = fields.stream().filter(field -> !document.hasNonNull(field)).toList(); + return outcome(missing.isEmpty() ? "MATCH" : "MISSING_FIELDS", fields, missing); + } catch (Exception invalid) { + // Parser diagnostics can contain file content; do not return or log them. + return outcome("INVALID_JSON", fields, List.of()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index 76c21ca2..320c082f 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -449,6 +449,42 @@ public class GeneratedFileCache { } } + /** One bounded read for an explicit content check, never a managed-scope certificate. */ + public record ArtifactRead(String status, byte[] bytes) { + public ArtifactRead { bytes = bytes == null ? null : bytes.clone(); } + @Override public byte[] bytes() { return bytes == null ? null : bytes.clone(); } + } + + public ArtifactRead readDurableArtifactSnapshot(String id, Long workspaceId, String conversationId, + String expectedDigest, int maxBytes) { + try { + if (workspaceId == null || conversationId == null) return new ArtifactRead("UNAVAILABLE", null); + Metadata before = availableMetadata(id, workspaceId, conversationId); + if (before == null) return new ArtifactRead("UNAVAILABLE", null); + int budget = Math.clamp(maxBytes, 0, 1_048_576); + if (budget == 0 || expectedDigest == null || !expectedDigest.matches("[0-9a-fA-F]{64}")) { + return new ArtifactRead("UNKNOWN", null); + } + Path bin = storageDir.resolve(id); + if (Files.size(bin) > budget) return new ArtifactRead("UNKNOWN", null); + byte[] bytes; + try (var input = Files.newInputStream(bin, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + bytes = input.readNBytes(budget + 1); + } + if (bytes.length > budget) return new ArtifactRead("UNKNOWN", null); + Metadata after = availableMetadata(id, workspaceId, conversationId); + if (after == null) return new ArtifactRead("UNAVAILABLE", null); + if (!before.equals(after)) return new ArtifactRead("UNKNOWN", null); + String actual = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + return expectedDigest.equalsIgnoreCase(actual) + ? new ArtifactRead("READ", bytes) : new ArtifactRead("STALE", null); + } catch (IOException | RuntimeException unavailable) { + return new ArtifactRead("UNAVAILABLE", null); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + private Metadata availableMetadata(String id, Long workspaceId, String conversationId) throws IOException { if (id == null || !ID_RE.matcher(id).matches()) return null; Path bin = storageDir.resolve(id).normalize(); diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java index 8945a191..fff75e7d 100644 --- a/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/ExecutionEvidenceQueryTest.java @@ -163,6 +163,50 @@ class ExecutionEvidenceQueryTest { assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity()); } + @Test void jsonChecksAreSourceAuthorizedAndDoNotInspectUnavailableFiles() { + when(store.findById(id)).thenReturn(Optional.of(evidence(id))); + assertThrows(MateClawException.class, () -> queries.checkJson("stranger", 1L, id, List.of("report"))); + assertThrows(MateClawException.class, () -> queries.checkJson("owner", 2L, id, List.of("report"))); + assertEquals("UNAVAILABLE", queries.checkJson("owner", 1L, id, List.of("report")).status()); + var artifact = new EvidenceObservation("artifact:private", EvidenceKind.ARTIFACT_SNAPSHOT, + EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, + null, "private", "digest", "private metadata", null, now, null); + when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", artifact))); + assertEquals("UNAVAILABLE", queries.checkJson("owner", 1L, id, List.of("report")).status()); + verifyNoInteractions(files); + } + + @Test void jsonCheckUsesRealDurableBytesWithoutPromotingEvidence(@org.junit.jupiter.api.io.TempDir java.nio.file.Path root) throws Exception { + var cache = new GeneratedFileCache(root); + byte[] bytes = "{\"report\":true}".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String artifactId = cache.put(bytes, "report.json", "application/json", new GeneratedFileCache.Owner(1L, 1L, "conv")); + String digest = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); + var observation = new EvidenceObservation("artifact:" + artifactId, EvidenceKind.ARTIFACT_SNAPSHOT, + EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, + null, artifactId, digest, "registered file", null, Instant.now(), Instant.now().plusSeconds(3600)); + when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", observation))); + var auth = mock(AuthService.class); + var user = new vip.mate.auth.model.UserEntity(); user.setId(1L); user.setRole("user"); + when(auth.findByUsername("owner")).thenReturn(user); + var workspaces = mock(WorkspaceService.class); + when(workspaces.hasPermissionCached(1L, 1L, "viewer")).thenReturn(true); + var properties = new ExecutionEvidenceProperties(); + queries = new ExecutionEvidenceQueryService(store, conversations, teams, cache, auth, + workspaces, properties, new SimpleMeterRegistry()); + var result = queries.checkJson("owner", 1L, id, List.of("report")); + assertEquals("MATCH", result.status()); + assertFalse(result.acceptanceEligible()); + assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity()); + assertEquals("MISSING_FIELDS", queries.checkJson("owner", 1L, id, List.of("appendix")).status()); + properties.setArtifactVersionCheckMaxBytes(0); + assertEquals("UNKNOWN", queries.checkJson("owner", 1L, id, List.of("report")).status()); + properties.setArtifactVersionCheckMaxBytes(1024); + java.nio.file.Files.writeString(root.resolve(artifactId), "{\"report\":false}"); + assertEquals("STALE", queries.checkJson("owner", 1L, id, List.of("report")).status()); + when(workspaces.hasPermissionCached(1L, 1L, "viewer")).thenReturn(false); + assertEquals("UNAVAILABLE", queries.checkJson("owner", 1L, id, List.of("report")).status()); + } + private ExecutionEvidenceQueryService.Page list(String user, Long workspace, String conversation, String cursor, Integer limit) { return queries.list(user, workspace, conversation, cursor, limit, null, null); } diff --git a/mateclaw-server/src/test/java/vip/mate/execution/evidence/JsonArtifactRecipeTest.java b/mateclaw-server/src/test/java/vip/mate/execution/evidence/JsonArtifactRecipeTest.java new file mode 100644 index 00000000..197ae0a2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/execution/evidence/JsonArtifactRecipeTest.java @@ -0,0 +1,40 @@ +package vip.mate.execution.evidence; + +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; +import vip.mate.execution.evidence.service.JsonArtifactRecipe; +import java.nio.charset.StandardCharsets; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class JsonArtifactRecipeTest { + private JsonArtifactRecipe.Result check(String json) { + return JsonArtifactRecipe.check(json.getBytes(StandardCharsets.UTF_8), List.of("report")); + } + @Test void checksOnlyNonNullTopLevelFieldsAndNeverGrantsAcceptance() { + var match = check("{\"report\":false,\"secret\":\"not returned\"}"); + assertEquals("MATCH", match.status()); + assertFalse(match.acceptanceEligible()); + assertEquals("json-required-fields", match.recipeId()); + assertEquals(1, match.recipeRevision()); + assertNotNull(match.checkedAt()); + assertFalse(match.toString().contains("not returned")); + assertEquals(List.of("report"), check("{\"nested\":{\"report\":1}}").missingFields()); + assertEquals("MISSING_FIELDS", check("{\"report\":null}").status()); + } + @Test void rejectsAmbiguousMalformedAndExcessiveJson() { + for (String text : List.of("", "null", "[]", "oops", "{\"report\":1} {}", + "{\"report\":1,\"report\":2}", "{\"report\":" + "[".repeat(40) + "0" + "]".repeat(40) + "}")) { + assertEquals("INVALID_JSON", check(text).status(), text); + } + assertEquals("UNKNOWN", JsonArtifactRecipe.check(new byte[1_048_577], List.of("report")).status()); + } + @Test void rejectsEmptyDuplicateAndOversizedRequirements() { + assertThrows(MateClawException.class, () -> JsonArtifactRecipe.validate(null)); + for (List fields : List.of(List.of(), List.of(""), List.of("x", "x"), + List.of("a\nb"), List.of("x".repeat(129)), java.util.stream.IntStream.range(0, 17) + .mapToObj(i -> "key" + i).toList())) { + assertEquals(400, assertThrows(MateClawException.class, () -> JsonArtifactRecipe.validate(fields)).getCode()); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileArtifactVersionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileArtifactVersionTest.java index 28bd6da8..1d094a71 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileArtifactVersionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileArtifactVersionTest.java @@ -65,5 +65,36 @@ class GeneratedFileArtifactVersionTest { Files.createSymbolicLink(root.resolve(id), outside); assertFalse(cache.isDurablyAvailable(id, 1L, "conv")); assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024)); + assertEquals("UNAVAILABLE", cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 1024).status()); } + @Test void boundedSnapshotReadsOnlyMatchingOwnedDurableBytes() throws Exception { + var cache = new GeneratedFileCache(root); + String id = put(cache, "report"); + var read = cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 6); + assertEquals("READ", read.status()); + assertEquals("report", new String(read.bytes(), StandardCharsets.UTF_8)); + read.bytes()[0] = 'X'; + assertEquals("report", new String(read.bytes(), StandardCharsets.UTF_8)); + assertEquals("UNKNOWN", cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 5).status()); + assertEquals("UNKNOWN", cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 0).status()); + assertEquals("UNKNOWN", cache.readDurableArtifactSnapshot(id, 1L, "conv", "fake", 10).status()); + assertEquals("UNAVAILABLE", cache.readDurableArtifactSnapshot(id, 2L, "conv", digest("report"), 10).status()); + assertEquals("UNAVAILABLE", cache.readDurableArtifactSnapshot(id, 1L, "other", digest("report"), 10).status()); + Files.writeString(root.resolve(id), "changed"); + var stale = cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 10); + assertEquals("STALE", stale.status()); + assertNull(stale.bytes()); + Files.delete(root.resolve(id)); + assertEquals("UNAVAILABLE", cache.readDurableArtifactSnapshot(id, 1L, "conv", digest("report"), 10).status()); + } + + @Test void snapshotReadHasAHardOneMebibyteLimit() throws Exception { + var cache = new GeneratedFileCache(root); + String content = "x".repeat(1_048_577); + String id = put(cache, content); + var result = cache.readDurableArtifactSnapshot(id, 1L, "conv", digest(content), Integer.MAX_VALUE); + assertEquals("UNKNOWN", result.status()); + assertNull(result.bytes()); + } + } diff --git a/mateclaw-ui/src/api/__tests__/executionEvidence.test.ts b/mateclaw-ui/src/api/__tests__/executionEvidence.test.ts index 54578ae0..9e344b47 100644 --- a/mateclaw-ui/src/api/__tests__/executionEvidence.test.ts +++ b/mateclaw-ui/src/api/__tests__/executionEvidence.test.ts @@ -18,4 +18,10 @@ describe('executionEvidenceApi', () => { adapter: async config => ({ data: { code: 403, msg: 'Forbidden', data: null }, status: 200, statusText: 'OK', headers: {}, config }), })).rejects.toMatchObject({ code: 403, message: 'Forbidden' }) }) + it('sends explicit requirements while preserving the evidence ID', () => { + const post = vi.spyOn(http, 'post').mockResolvedValue({} as never) + executionEvidenceApi.checkJson('9223372036854775804', ['report', 'appendix']) + expect(post).toHaveBeenCalledWith('/execution-evidence/9223372036854775804/json-check', { requiredFields: ['report', 'appendix'] }) + }) + }) diff --git a/mateclaw-ui/src/api/executionEvidence.ts b/mateclaw-ui/src/api/executionEvidence.ts index 60b4408b..b12080f4 100644 --- a/mateclaw-ui/src/api/executionEvidence.ts +++ b/mateclaw-ui/src/api/executionEvidence.ts @@ -26,7 +26,17 @@ export interface ExecutionEvidenceQuery { cursor?: string limit?: number } +export interface ArtifactJsonCheck { + recipeId: string + recipeRevision: number + status: 'MATCH' | 'MISSING_FIELDS' | 'INVALID_JSON' | 'UNKNOWN' | 'STALE' | 'UNAVAILABLE' + requiredFields: string[] + missingFields: string[] + checkedAt: string + acceptanceEligible: false +} export const executionEvidenceApi = { list: (params: ExecutionEvidenceQuery) => http.get('/execution-evidence', { params }), + checkJson: (id: string, requiredFields: string[]) => http.post(`/execution-evidence/${encodeURIComponent(id)}/json-check`, { requiredFields }), get: (id: string) => http.get(`/execution-evidence/${encodeURIComponent(id)}`), } diff --git a/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue b/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue index ae6511fe..a8faac2a 100644 --- a/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue +++ b/mateclaw-ui/src/components/execution/ExecutionEvidenceList.vue @@ -1,7 +1,7 @@