From d96eb4a99178b8f161f8c1f397054bdd8eda779c Mon Sep 17 00:00:00 2001 From: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:32:11 +0530 Subject: [PATCH] feat: add JSON verifier output Signed-off-by: Shreyansh Sancheti <43677304+shreyanshjain7174@users.noreply.github.com> --- README.md | 3 +- cmd/agentgate-verify/main.go | 54 ++++++++++++++++++++- cmd/agentgate-verify/main_test.go | 80 +++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 58f106c..e10be6a 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,8 @@ No network call happens during verification itself — `trust.json` is a pinned local file, and `agentgate-verify` only reads the local SQLite file. Pass `--expected-head :` (from a checkpoint recorded separately, e.g. at handoff to an auditor) to also assert completeness, not just chain -integrity. +integrity. For scripts, add `--format json` to receive one machine-readable +result object while keeping the same exit codes. ### Development option diff --git a/cmd/agentgate-verify/main.go b/cmd/agentgate-verify/main.go index 917e2fe..aa68d17 100644 --- a/cmd/agentgate-verify/main.go +++ b/cmd/agentgate-verify/main.go @@ -30,6 +30,7 @@ import ( "bufio" "bytes" "database/sql" + "encoding/json" "flag" "fmt" "io" @@ -52,14 +53,20 @@ func run(args []string, stdout, stderr io.Writer) int { path string trustRoot string expectedHead string + outputFormat string ) fs.StringVar(&source, "source", "", "receipt source: sqlite | jsonl") fs.StringVar(&path, "path", "", "input path; '-' means stdin (jsonl source only)") fs.StringVar(&trustRoot, "trust-root", "", "path to a JSON trust file; optional if the jsonl source embeds its own keys") fs.StringVar(&expectedHead, "expected-head", "", "optional SEQ:HEXHASH; overrides a manifest-derived expected head") + fs.StringVar(&outputFormat, "format", "text", "output format: text | json") if err := fs.Parse(args); err != nil { return 2 } + if outputFormat != "text" && outputFormat != "json" { + fmt.Fprintf(stderr, "agentgate-verify: --format must be text or json, got %q\n", outputFormat) + return 2 + } if source != "sqlite" && source != "jsonl" { fmt.Fprintf(stderr, "agentgate-verify: --source must be sqlite or jsonl, got %q\n", source) @@ -138,8 +145,20 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "agentgate-verify: %v\n", err) return 2 } + rangeKind := "" + if manifest != nil { + if manifest.ResolvedTo == manifest.HeadSeq { + rangeKind = "full" + } else { + rangeKind = "partial" + } + } if result.OK { + if outputFormat == "json" { + writeJSONResult(stdout, result, rangeKind) + return 0 + } fmt.Fprintf(stdout, "PASS: %d receipts verified, head seq=%d hash=%x\n", result.VerifiedCount, result.HeadSeq, result.HeadEntryHash[:8]) if result.Complete { @@ -148,7 +167,7 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, "completeness: not claimed (no --expected-head supplied)") } if manifest != nil { - if manifest.ResolvedTo == manifest.HeadSeq { + if rangeKind == "full" { fmt.Fprintln(stdout, "range: full (reaches the database's true head at export time)") } else { fmt.Fprintf(stdout, "range: partial (head at export time was seq=%d)\n", manifest.HeadSeq) @@ -156,12 +175,45 @@ func run(args []string, stdout, stderr io.Writer) int { } return 0 } + if outputFormat == "json" { + writeJSONResult(stdout, result, rangeKind) + return 1 + } fmt.Fprintf(stderr, "FAIL: seq=%d reason=%s (%d of %d receipts verified before failure)\n", result.FailedAtSeq, result.Reason, result.VerifiedCount, result.TotalReceipts) return 1 } +type jsonResult struct { + OK bool `json:"ok"` + TotalReceipts int `json:"total_receipts"` + VerifiedCount int `json:"verified_count"` + FailedAtSeq uint64 `json:"failed_at_seq,omitempty"` + Reason string `json:"reason,omitempty"` + HeadSeq uint64 `json:"head_seq,omitempty"` + HeadEntryHash string `json:"head_entry_hash,omitempty"` + Complete bool `json:"complete"` + Range string `json:"range,omitempty"` +} + +func writeJSONResult(stdout io.Writer, result receipt.VerifyResult, rangeKind string) { + output := jsonResult{ + OK: result.OK, + TotalReceipts: result.TotalReceipts, + VerifiedCount: result.VerifiedCount, + FailedAtSeq: result.FailedAtSeq, + Reason: result.Reason, + HeadSeq: result.HeadSeq, + Complete: result.Complete, + Range: rangeKind, + } + if result.OK { + output.HeadEntryHash = fmt.Sprintf("%x", result.HeadEntryHash) + } + _ = json.NewEncoder(stdout).Encode(output) +} + // readSQLite reads every row of the receipts table, ordered by seq, from a // local file. It never writes to the database. func readSQLite(path string) ([]receipt.Receipt, error) { diff --git a/cmd/agentgate-verify/main_test.go b/cmd/agentgate-verify/main_test.go index 06dba4e..0dfbbb9 100644 --- a/cmd/agentgate-verify/main_test.go +++ b/cmd/agentgate-verify/main_test.go @@ -144,6 +144,86 @@ func TestRun_SQLite_ValidChainExitsZero(t *testing.T) { } } +func TestRun_JSONFormat_ValidChain(t *testing.T) { + dbPath, trustPath, _ := buildTestChain(t, 5) + + var stdout, stderr bytes.Buffer + code := run([]string{"--source", "sqlite", "--path", dbPath, "--trust-root", trustPath, "--format", "json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr = %s", code, stderr.String()) + } + var result struct { + OK bool `json:"ok"` + VerifiedCount int `json:"verified_count"` + HeadSeq uint64 `json:"head_seq"` + HeadEntryHash string `json:"head_entry_hash"` + Complete bool `json:"complete"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("stdout is not JSON: %v; stdout=%s", err, stdout.String()) + } + if !result.OK || result.VerifiedCount != 5 || result.HeadSeq != 5 { + t.Fatalf("result = %+v, want valid 5-receipt chain", result) + } + if len(result.HeadEntryHash) != 64 || result.Complete || result.Reason != "" { + t.Fatalf("result = %+v, want safe full hash, no completeness claim, and no reason", result) + } +} + +func TestRun_JSONFormat_TamperedChain(t *testing.T) { + dbPath, trustPath, _ := buildTestChain(t, 3) + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + dropAppendOnlyTriggers(t, db) + if _, err := db.Exec(`UPDATE receipts SET status_code = 404 WHERE seq = 2`); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{"--source", "sqlite", "--path", dbPath, "--trust-root", trustPath, "--format", "json"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("exit code = %d, want 1; stderr = %s", code, stderr.String()) + } + var result struct { + OK bool `json:"ok"` + FailedAtSeq uint64 `json:"failed_at_seq"` + Reason string `json:"reason"` + VerifiedCount int `json:"verified_count"` + TotalReceipts int `json:"total_receipts"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("stdout is not JSON: %v; stdout=%s", err, stdout.String()) + } + if result.OK || result.FailedAtSeq != 2 || result.Reason != receipt.ReasonEntryHashMismatch || result.VerifiedCount != 1 || result.TotalReceipts != 3 { + t.Fatalf("result = %+v, want entry-hash failure at seq 2", result) + } +} + +func TestRun_TextFormatMatchesDefault(t *testing.T) { + dbPath, trustPath, _ := buildTestChain(t, 2) + args := []string{"--source", "sqlite", "--path", dbPath, "--trust-root", trustPath} + + var defaultOut, defaultErr, textOut, textErr bytes.Buffer + defaultCode := run(args, &defaultOut, &defaultErr) + textCode := run(append(args, "--format", "text"), &textOut, &textErr) + if defaultCode != textCode || !bytes.Equal(defaultOut.Bytes(), textOut.Bytes()) || !bytes.Equal(defaultErr.Bytes(), textErr.Bytes()) { + t.Fatalf("default (%d, %q, %q) != text (%d, %q, %q)", defaultCode, defaultOut.String(), defaultErr.String(), textCode, textOut.String(), textErr.String()) + } +} + +func TestRun_InvalidFormatExitsTwo(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"--source", "jsonl", "--path", "x", "--format", "yaml"}, &stdout, &stderr) + if code != 2 || !bytes.Contains(stderr.Bytes(), []byte(`--format must be text or json`)) { + t.Fatalf("result = (%d, %q, %q), want format error", code, stdout.String(), stderr.String()) + } +} + // TestRun_SQLite_ModifiedRowExitsOne covers VER-05 end to end. func TestRun_SQLite_ModifiedRowExitsOne(t *testing.T) { dbPath, trustPath, _ := buildTestChain(t, 5)