feat(evidence): inspect artifact version changes on demand

This commit is contained in:
mateaix 2026-09-13 21:30:39 +08:00
parent b04b0f673b
commit 3843dc04b7
8 changed files with 241 additions and 15 deletions

View File

@ -9,6 +9,9 @@ public class ExecutionEvidenceProperties {
public enum Mode { OFF, OBSERVE, ENFORCE }
private Mode mode = Mode.OBSERVE;
private int retentionDays = 90;
private int artifactVersionCheckMaxBytes = 1_048_576;
public int getArtifactVersionCheckMaxBytes() { return artifactVersionCheckMaxBytes; }
public void setArtifactVersionCheckMaxBytes(int value) { artifactVersionCheckMaxBytes = Math.clamp(value, 0, 16_777_216); }
private int cleanupMaxBatches = 10;
public int getCleanupMaxBatches() { return cleanupMaxBatches; }
public void setCleanupMaxBatches(int value) { cleanupMaxBatches = Math.clamp(value, 1, 100); }

View File

@ -68,7 +68,7 @@ public class ExecutionEvidenceQueryService {
if (page.isEmpty()) return new Page(List.of(), null);
var attempts = store.findAttempts(canonicalWorkspace, conversationId,
page.stream().map(ExecutionEvidence::attemptId).distinct().toList());
return new Page(page.stream().map(row -> view(username, row, attempts.get(row.attemptId()))).toList(),
return new Page(page.stream().map(row -> view(username, row, attempts.get(row.attemptId()), false)).toList(),
hasMore ? encode(page.getLast()) : null);
} finally {
metrics.timer("mateclaw.execution.evidence.query.latency").record(
@ -81,7 +81,7 @@ public class ExecutionEvidenceQueryService {
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));
return view(username, row, store.findAttempt(row.attemptId()).orElseThrow(this::hidden), true);
}
private Long authorize(String username, Long workspaceId, String conversationId) {
@ -94,7 +94,7 @@ public class ExecutionEvidenceQueryService {
return conversation.getWorkspaceId();
}
private View view(String username, ExecutionEvidence row, ExecutionAttempt attempt) {
private View view(String username, ExecutionEvidence row, ExecutionAttempt attempt, boolean inspectVersion) {
if (attempt == null || !Objects.equals(attempt.id(), row.attemptId())) throw hidden();
if (!Objects.equals(attempt.identity().workspaceId(), row.workspaceId())
|| !Objects.equals(attempt.identity().conversationId(), row.conversationId())) throw hidden();
@ -116,6 +116,16 @@ public class ExecutionEvidenceQueryService {
} else if (!files.isDurablyAvailable(artifact, row.workspaceId(), row.conversationId())) {
validity = "UNAVAILABLE";
artifact = null;
} else if (inspectVersion && !"UNAVAILABLE".equals(validity)) {
var version = files.probeDurableArtifactVersion(artifact, row.workspaceId(), row.conversationId(),
digest, properties.getArtifactVersionCheckMaxBytes());
if (version == GeneratedFileCache.ArtifactVersion.CHANGED) {
validity = "STALE";
} else if (version == GeneratedFileCache.ArtifactVersion.UNAVAILABLE) {
validity = "UNAVAILABLE";
artifact = null;
}
// Equality/budget exhaustion remains UNKNOWN; there is no managed generation fence.
}
}
metrics.counter("mateclaw.execution.evidence.validity", "status", validity).increment();

View File

@ -21,6 +21,8 @@ import java.time.Instant;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
@ -396,23 +398,74 @@ public class GeneratedFileCache {
}
}
/** No positive verification state: a matching shared file is still unverified. */
public enum ArtifactVersion { UNVERIFIED, CHANGED, UNAVAILABLE }
/** Bounded metadata-only probe. Availability does not imply that current content is verified. */
public boolean isDurablyAvailable(String id, Long workspaceId, String conversationId) {
if (id == null || !ID_RE.matcher(id).matches()) return false;
Path bin = storageDir.resolve(id).normalize();
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
try {
if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin)
|| !Files.isRegularFile(meta) || Files.size(meta) > 16_384) return false;
Metadata stored = parseMeta(Files.readString(meta), id);
return stored.expireAt() > System.currentTimeMillis()
&& Objects.equals(workspaceId, stored.workspaceId())
&& Objects.equals(conversationId, stored.conversationId());
} catch (Exception unavailable) {
return availableMetadata(id, workspaceId, conversationId) != null;
} catch (IOException | RuntimeException unavailable) {
return false;
}
}
/**
* On-demand bounded comparison with a historical snapshot, never a freshness certificate.
* A changed digest is useful negative evidence; equality cannot exclude concurrent writers.
*/
public ArtifactVersion probeDurableArtifactVersion(String id, Long workspaceId, String conversationId,
String expectedDigest, int maxBytes) {
try {
Metadata before = availableMetadata(id, workspaceId, conversationId);
if (before == null) return ArtifactVersion.UNAVAILABLE;
int budget = Math.clamp(maxBytes, 0, 16_777_216);
if (budget == 0 || expectedDigest == null || !expectedDigest.matches("[0-9a-fA-F]{64}")) {
return ArtifactVersion.UNVERIFIED;
}
Path bin = storageDir.resolve(id);
if (Files.size(bin) > budget) return ArtifactVersion.UNVERIFIED;
MessageDigest digest = MessageDigest.getInstance("SHA-256");
int total = 0;
byte[] buffer = new byte[8192];
try (var input = Files.newInputStream(bin, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
int read;
// At most budget+1 bytes even if a concurrent writer grows the file.
while ((read = input.read(buffer, 0, Math.min(buffer.length, budget + 1 - total))) != -1) {
total += read;
if (total > budget) return ArtifactVersion.UNVERIFIED;
digest.update(buffer, 0, read);
}
}
Metadata after = availableMetadata(id, workspaceId, conversationId);
if (after == null) return ArtifactVersion.UNAVAILABLE;
if (!before.equals(after)) return ArtifactVersion.UNVERIFIED;
return expectedDigest.equalsIgnoreCase(HexFormat.of().formatHex(digest.digest()))
? ArtifactVersion.UNVERIFIED : ArtifactVersion.CHANGED;
} catch (IOException | RuntimeException unavailable) {
return ArtifactVersion.UNAVAILABLE;
} 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();
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin, LinkOption.NOFOLLOW_LINKS)
|| !Files.isRegularFile(meta, LinkOption.NOFOLLOW_LINKS)) return null;
byte[] raw;
try (var input = Files.newInputStream(meta, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
raw = input.readNBytes(16_385);
}
if (raw.length > 16_384) return null;
Metadata stored = parseMeta(new String(raw, StandardCharsets.UTF_8), id);
return stored.expireAt() > System.currentTimeMillis()
&& Objects.equals(workspaceId, stored.workspaceId())
&& Objects.equals(conversationId, stored.conversationId()) ? stored : null;
}
private Entry loadFromDisk(String id) {
Path bin = storageDir.resolve(id).normalize();
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();

View File

@ -134,6 +134,8 @@ mateclaw:
execution-evidence:
# Observe receipts only. Enforcement requires managed verification scopes and is not available yet.
mode: observe
# On-demand detail probe only; matching bytes remain UNKNOWN without managed scopes. 0 disables.
artifact-version-check-max-bytes: 1048576
retention-days: 90
max-summary-bytes: 2048
max-observations: 32

View File

@ -100,6 +100,7 @@ class ExecutionEvidenceQueryTest {
assertEquals("UNAVAILABLE", unavailable.validity());
assertNull(unavailable.artifactRef());
assertNull(unavailable.artifactDigest());
verifyNoInteractions(files);
}
@Test void emptyPageDoesNotLoadAttempts() {
@ -135,6 +136,33 @@ class ExecutionEvidenceQueryTest {
() -> list("owner", 1L, "conv", null, null)).getCode());
}
@Test void detailDetectsChangedPersistedArtifact(@org.junit.jupiter.api.io.TempDir java.nio.file.Path root) throws Exception {
var cache = new GeneratedFileCache(root);
byte[] bytes = "original report".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String artifactId = cache.put(bytes, "report.txt", "text/plain", 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 admin = new vip.mate.auth.model.UserEntity();
admin.setId(1L); admin.setRole("admin");
when(auth.findByUsername("owner")).thenReturn(admin);
var properties = new ExecutionEvidenceProperties();
queries = new ExecutionEvidenceQueryService(store, conversations, teams, cache, auth,
mock(WorkspaceService.class), properties, new SimpleMeterRegistry());
assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity());
java.nio.file.Files.writeString(root.resolve(artifactId), "externally replaced");
assertEquals("STALE", queries.detail("owner", 1L, id).validity());
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(21), isNull(), isNull()))
.thenReturn(List.of(new ExecutionEvidence(id, 1L, 1L, "conv", observation)));
assertEquals("UNKNOWN", list("owner", 1L, "conv", null, 20).items().getFirst().validity(),
"pagination must remain metadata-only");
properties.setArtifactVersionCheckMaxBytes(0);
assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity());
}
private ExecutionEvidenceQueryService.Page list(String user, Long workspace, String conversation, String cursor, Integer limit) {
return queries.list(user, workspace, conversation, cursor, limit, null, null);
}

View File

@ -0,0 +1,69 @@
package vip.mate.tool.document;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import static org.junit.jupiter.api.Assertions.*;
import static vip.mate.tool.document.GeneratedFileCache.ArtifactVersion.*;
class GeneratedFileArtifactVersionTest {
@TempDir Path root;
private String put(GeneratedFileCache cache, String text) {
return cache.put(text.getBytes(StandardCharsets.UTF_8), "report.txt", "text/plain",
new GeneratedFileCache.Owner(1L, 1L, "conv"));
}
private String digest(String text) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8)));
}
@Test void matchingBytesRemainUnverifiedAndChangedBytesAreDetected() throws Exception {
var cache = new GeneratedFileCache(root);
String id = put(cache, "report");
assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
Files.writeString(root.resolve(id), "different report");
assertEquals(CHANGED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
assertEquals("report", new String(cache.get(id).orElseThrow().bytes(), StandardCharsets.UTF_8),
"probe reads durable bytes, not the old hot cache");
}
@Test void overBudgetDisabledAndInvalidDigestNeverPassOrClaimChange() throws Exception {
var cache = new GeneratedFileCache(root);
String id = put(cache, "report");
assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("different"), 2));
assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("different"), 0));
assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", "bad digest", 1024));
}
@Test void missingForeignExpiredAndOversizedMetadataAreUnavailable() throws Exception {
var cache = new GeneratedFileCache(root);
String id = put(cache, "report");
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 2L, "conv", digest("report"), 1024));
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "other", digest("report"), 1024));
Path meta = root.resolve(id + ".meta");
String original = Files.readString(meta);
Files.writeString(meta, "0" + original.substring(original.indexOf('\t')));
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
Files.writeString(meta, "x".repeat(16_385));
assertFalse(cache.isDurablyAvailable(id, 1L, "conv"));
Files.writeString(meta, original);
Files.delete(root.resolve(id));
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
}
@org.junit.jupiter.api.condition.EnabledOnOs({org.junit.jupiter.api.condition.OS.LINUX, org.junit.jupiter.api.condition.OS.MAC})
@Test void symbolicLinksAreNotFollowedByTheProbe() throws Exception {
var cache = new GeneratedFileCache(root);
String id = put(cache, "report");
Path outside = Files.writeString(root.resolve("outside"), "report");
Files.delete(root.resolve(id));
Files.createSymbolicLink(root.resolve(id), outside);
assertFalse(cache.isDurablyAvailable(id, 1L, "conv"));
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
}
}

View File

@ -11,11 +11,15 @@ const loading = ref(false)
const errorKey = ref('')
const items = ref<ExecutionEvidence[]>([])
const nextCursor = ref<string | null>(null)
const detailLoading = ref<Record<string, boolean>>({})
const detailErrors = ref<Record<string, string>>({})
let generation = 0
async function load(more = false) {
if (loading.value || !props.conversationId) return
const request = ++generation
detailLoading.value = {}
detailErrors.value = {}
loading.value = true
errorKey.value = ''
try {
@ -38,6 +42,29 @@ async function load(more = false) {
if (request === generation) loading.value = false
}
}
async function loadDetail(id: string, event: Event) {
if (!(event.target as HTMLDetailsElement).open || detailLoading.value[id]) return
const request = generation
detailLoading.value[id] = true
delete detailErrors.value[id]
try {
const { data } = await executionEvidenceApi.get(id)
if (request !== generation) return
items.value = items.value.map(item => item.id === id ? data : item)
} catch (error) {
if (request !== generation) return
const failure = error as { code?: number; response?: { status?: number } }
const code = failure.response?.status ?? failure.code
if (code === 401 || code === 403 || code === 404) {
items.value = items.value.filter(item => item.id !== id)
errorKey.value = 'executionEvidence.accessError'
} else {
detailErrors.value[id] = 'executionEvidence.loadError'
}
} finally {
if (request === generation) delete detailLoading.value[id]
}
}
function toggle() {
expanded.value = !expanded.value
if (expanded.value && !loaded.value) void load()
@ -45,6 +72,8 @@ function toggle() {
watch(() => [props.conversationId, props.goalId, props.teamTaskId], () => {
generation++
items.value = []
detailLoading.value = {}
detailErrors.value = {}
nextCursor.value = null
errorKey.value = ''
loaded.value = false
@ -80,8 +109,10 @@ onBeforeUnmount(() => { generation++ })
<div><dt>{{ t('executionEvidence.observedAt') }}</dt><dd><time :datetime="item.observedAt">{{ item.observedAt }}</time></dd></div>
<div v-if="item.expiresAt"><dt>{{ t('executionEvidence.expiresAt') }}</dt><dd><time :datetime="item.expiresAt">{{ item.expiresAt }}</time></dd></div>
</dl>
<details>
<details @toggle="loadDetail(item.id, $event)" :aria-busy="!!detailLoading[item.id]">
<summary>{{ t('executionEvidence.details') }}</summary>
<p v-if="detailLoading[item.id]" role="status">{{ t('common.loading') }}</p>
<p v-if="detailErrors[item.id]" role="alert">{{ t(detailErrors[item.id]) }}</p>
<dl>
<div><dt>{{ t('executionEvidence.id') }}</dt><dd>{{ item.id }}</dd></div>
<div><dt>{{ t('executionEvidence.attemptId') }}</dt><dd>{{ item.attemptId }}</dd></div>

View File

@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import ExecutionEvidenceList from '../ExecutionEvidenceList.vue'
import { executionEvidenceApi } from '@/api/executionEvidence'
import en from '@/i18n/locales/en-US'
vi.mock('@/api/executionEvidence', () => ({ executionEvidenceApi: { list: vi.fn() } }))
vi.mock('@/api/executionEvidence', () => ({ executionEvidenceApi: { list: vi.fn(), get: vi.fn() } }))
const apps: ReturnType<typeof createApp>[] = []
const row = (id: string) => ({ id, attemptId: '9223372036854775801', conversationId: 'one', toolName: 'execCommand', state: 'SUCCEEDED', effectOutcome: 'CONFIRMED', kind: 'COMMAND_EXIT', result: 'OBSERVED', sourceLevel: 'RUNTIME', validity: 'UNKNOWN', summary: 'Exit code: 0', observedAt: '2026-09-07T12:00:00Z', expiresAt: null, artifactRef: null, artifactDigest: null })
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
@ -42,4 +42,34 @@ describe('execution evidence', () => {
resolve({ data: { items: [row('old')], nextCursor: null } }); await flush()
expect(host.querySelector('[data-evidence-item]')?.getAttribute('data-evidence-item')).toBe('new')
})
it('refreshes the selected detail lazily and displays a changed artifact as stale', async () => {
vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [row('artifact')], nextCursor: null } } as never)
vi.mocked(executionEvidenceApi.get).mockResolvedValue({ data: { ...row('artifact'), validity: 'STALE' } } as never)
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
expect(executionEvidenceApi.get).not.toHaveBeenCalled()
const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush()
expect(executionEvidenceApi.get).toHaveBeenCalledWith('artifact')
expect(host.textContent).toContain(en.executionEvidence.validity.STALE)
})
it('drops a detail response after the conversation changes', async () => {
let resolve!: (value: unknown) => void
vi.mocked(executionEvidenceApi.list).mockResolvedValueOnce({ data: { items: [row('old')], nextCursor: null } } as never)
.mockResolvedValueOnce({ data: { items: [row('new')], nextCursor: null } } as never)
vi.mocked(executionEvidenceApi.get).mockImplementationOnce(() => new Promise(r => { resolve = r }) as never)
const { host, props } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush()
props.conversationId = 'two'; await flush()
resolve({ data: { ...row('old'), summary: 'stale sensitive detail' } }); await flush()
expect(host.textContent).not.toContain('stale sensitive detail')
expect(host.querySelector('[data-evidence-item]')?.getAttribute('data-evidence-item')).toBe('new')
})
it('removes the old row when detail authorization is revoked', async () => {
vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [row('old')], nextCursor: null } } as never)
vi.mocked(executionEvidenceApi.get).mockRejectedValue({ code: 404 })
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush()
expect(host.querySelectorAll('[data-evidence-item]')).toHaveLength(0)
expect(host.querySelector('[role="alert"]')?.textContent).toContain('permission')
})
})