Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <seq>:<hash>` (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

Expand Down
54 changes: 53 additions & 1 deletion cmd/agentgate-verify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"bufio"
"bytes"
"database/sql"
"encoding/json"
"flag"
"fmt"
"io"
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Comment on lines +148 to +152
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 {
Expand All @@ -148,20 +167,53 @@ 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)
}
}
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) {
Expand Down
80 changes: 80 additions & 0 deletions cmd/agentgate-verify/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading