Publish evidence context in JSON output bundles

This commit is contained in:
2026-07-27 18:23:48 +00:00
parent e61e522662
commit 256cc98ddb
6 changed files with 276 additions and 31 deletions

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkmap"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/evidencecontext"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
@@ -338,6 +339,82 @@ func TestEncodeRejectsInvalidChunkMapArtifact(t *testing.T) {
}
}
func TestEncodeIncludesValidatedEvidenceContext(t *testing.T) {
artifact := acceptedEvidenceContextArtifact(t)
result, err := New().Encode(context.Background(), contracts.OutputRequest{EvidenceContext: &artifact})
if err != nil {
t.Fatalf("Encode() error = %v", err)
}
if got := string(fileBytes(t, result.Files, evidenceContextFileName)); !strings.HasSuffix(got, "\n") || !stdjson.Valid([]byte(got)) {
t.Fatalf("evidence context file = %q, want pretty valid newline-terminated JSON", got)
}
value, err := evidencecontext.New().Decode(fileBytes(t, result.Files, evidenceContextFileName))
if err != nil {
t.Fatalf("Decode(evidence context file) error = %v", err)
}
if len(value.Contexts) != 0 {
t.Fatalf("evidence context = %#v, want explicit empty contexts", value)
}
index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
if got, want := index["evidence_context"], map[string]any{
"artifact_kind": string(evidencecontext.ArtifactKind),
"file": evidenceContextFileName,
"media_type": evidencecontext.MediaType,
"schema_id": evidencecontext.SchemaID,
"schema_name": evidencecontext.SchemaName,
"schema_version": evidencecontext.SchemaVersion,
}; !reflect.DeepEqual(got, want) {
t.Fatalf("evidence context descriptor = %#v, want %#v", got, want)
}
for _, entry := range index["output_files"].([]any) {
if entry.(map[string]any)["file"] == evidenceContextFileName {
t.Fatalf("output_files = %#v, want no evidence context lane entry", index["output_files"])
}
}
}
func TestEncodeRejectsInvalidEvidenceContextArtifactWithoutContentLeakage(t *testing.T) {
artifact := acceptedEvidenceContextArtifact(t)
for _, test := range []struct {
name string
mutate func(*contracts.SerializedArtifact)
}{
{name: "kind", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Kind = "other/evidence" }},
{name: "schema identity", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.Version = "v2" }},
{name: "schema digest", mutate: func(artifact *contracts.SerializedArtifact) { artifact.Schema.JSONSchema[0] = '[' }},
{name: "media type", mutate: func(artifact *contracts.SerializedArtifact) { artifact.MediaType = "text/plain" }},
{name: "payload", mutate: func(artifact *contracts.SerializedArtifact) {
artifact.Content = []byte(`{"source_id":"secret transcript text"}`)
}},
} {
t.Run(test.name, func(t *testing.T) {
candidate := contracts.CloneSerializedArtifact(artifact)
test.mutate(&candidate)
_, err := New().Encode(context.Background(), contracts.OutputRequest{EvidenceContext: &candidate})
if err == nil || err.Error() != "json output encoder: evidence context artifact is invalid" {
t.Fatalf("Encode() error = %v, want fixed evidence artifact error", err)
}
if strings.Contains(err.Error(), "secret transcript text") {
t.Fatalf("Encode() leaked artifact content: %v", err)
}
})
}
}
func TestEncodeOmitsEvidenceContextWhenArtifactIsAbsent(t *testing.T) {
result, err := New().Encode(context.Background(), contracts.OutputRequest{Manifest: artifacts.RunManifest{RunID: "run-1"}})
if err != nil {
t.Fatalf("Encode() error = %v", err)
}
if got := outputFileNames(result.Files); containsString(got, evidenceContextFileName) {
t.Fatalf("file names = %#v, want no evidence context", got)
}
index := decodeObject(t, fileBytes(t, result.Files, "index.json"))
if _, ok := index["evidence_context"]; ok {
t.Fatalf("index = %#v, want no evidence context descriptor", index)
}
}
func TestEncodeChunkMapDoesNotMutateRequest(t *testing.T) {
artifact := acceptedChunkMapArtifact(t)
artifact.Metadata = map[string]any{"owner": "caller"}
@@ -649,6 +726,33 @@ func acceptedChunkMapArtifactWithPlanAnnotation(t *testing.T, annotation stdjson
return artifact
}
func acceptedEvidenceContextArtifact(t *testing.T) contracts.SerializedArtifact {
t.Helper()
document := &source.SourceDocument{
ID: "source-1",
Kind: "text",
Format: "text/plain",
Units: []source.SourceUnit{{
ID: 7,
Kind: "text",
Text: "Source content retained only in the evidence artifact.",
Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 7, EndUnitID: 7},
}},
}
digest, err := source.DigestDocument(document)
if err != nil {
t.Fatal(err)
}
document.Digest = digest
artifact, err := evidencecontext.Serialize(evidencecontext.BuildRequest{
Source: document, WindowUnits: 3, SelectedLanes: []string{"spells"},
})
if err != nil {
t.Fatal(err)
}
return artifact
}
func outputFileNames(files []contracts.OutputFile) []string {
names := make([]string, 0, len(files))
for _, file := range files {