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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ integrity. For scripts, add `--format json` to receive one machine-readable
result object while keeping the same exit codes. Add `--quiet` (or `-q`) to
text output to print only the `PASS:` summary on successful verification.

### Verify receipts from other producers

The receipt protocol lives in a public, standard-library-only Go package:
[`pkg/receiptspec`](pkg/receiptspec). It holds the receipt type, canonical
hashing, Ed25519 signing, chain and export verification, and JSONL encoding.
The gateway and `agentgate-verify` both use it, so there is one
implementation. Another producer, such as the Clawdlinux operator, builds a
chain with `receiptspec.NewChain` and any `agentgate-verify` checks it with
`--source jsonl`. To record decisions instead of API calls, see
[`pkg/receiptspec/DECISION.md`](pkg/receiptspec/DECISION.md).

### Development option

To build from source instead of pulling the release image — useful when
Expand Down
116 changes: 30 additions & 86 deletions cmd/agentgate-verify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@ Licensed under the Apache License, Version 2.0.
package main

import (
"bufio"
"bytes"
"database/sql"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
Expand All @@ -39,6 +38,7 @@ import (
_ "modernc.org/sqlite"

"github.com/Clawdlinux/agentgate/internal/receipt"
"github.com/Clawdlinux/agentgate/pkg/receiptspec"
)

func main() {
Expand Down Expand Up @@ -80,23 +80,23 @@ func run(args []string, stdout, stderr io.Writer) int {
return 2
}

var explicitTrust []receipt.TrustedKey
var explicitTrust []receiptspec.TrustedKey
if trustRoot != "" {
trustData, err := os.ReadFile(trustRoot)
if err != nil {
fmt.Fprintf(stderr, "agentgate-verify: read trust root: %v\n", err)
return 2
}
explicitTrust, err = receipt.LoadTrustedKeys(trustData)
explicitTrust, err = receiptspec.LoadTrustedKeys(trustData)
if err != nil {
fmt.Fprintf(stderr, "agentgate-verify: %v\n", err)
return 2
}
}

var explicitExpected *receipt.ExpectedHead
var explicitExpected *receiptspec.ExpectedHead
if expectedHead != "" {
eh, err := receipt.ParseExpectedHead(expectedHead)
eh, err := receiptspec.ParseExpectedHead(expectedHead)
if err != nil {
fmt.Fprintf(stderr, "agentgate-verify: %v\n", err)
return 2
Expand All @@ -105,45 +105,31 @@ func run(args []string, stdout, stderr io.Writer) int {
}

var (
receipts []receipt.Receipt
embeddedKeys []receipt.TrustedKey
manifest *receipt.ExportManifest
err error
bundle receiptspec.Bundle
err error
)
switch source {
case "sqlite":
receipts, err = readSQLite(path)
bundle.Receipts, err = readSQLite(path)
case "jsonl":
receipts, embeddedKeys, manifest, err = readJSONL(path)
bundle, err = readJSONL(path)
}
if err != nil {
fmt.Fprintf(stderr, "agentgate-verify: read %s: %v\n", source, err)
return 2
}

trustedKeys := explicitTrust
if len(trustedKeys) == 0 {
if len(embeddedKeys) == 0 {
fmt.Fprintln(stderr, "agentgate-verify: --trust-root is required (the source has no embedded keys)")
return 2
}
trustedKeys = embeddedKeys
if len(explicitTrust) == 0 && len(bundle.Keys) == 0 {
fmt.Fprintln(stderr, "agentgate-verify: --trust-root is required (the source has no embedded keys)")
return 2
}
manifest := bundle.Manifest

anchor := receipt.Anchor{}
expected := explicitExpected
if manifest != nil {
if err := receipt.VerifyManifest(*manifest, trustedKeys, embeddedKeys); err != nil {
fmt.Fprintf(stderr, "agentgate-verify: manifest: %v\n", err)
return 1
}
anchor = receipt.Anchor{Seq: manifest.AnchorSeq, EntryHash: manifest.AnchorHash}
if expected == nil {
expected = &receipt.ExpectedHead{Seq: manifest.ResolvedTo, EntryHash: manifest.LastEntryHash}
}
result, err := receiptspec.VerifyBundle(bundle, explicitTrust, explicitExpected)
var manifestErr *receiptspec.ManifestError
if errors.As(err, &manifestErr) {
fmt.Fprintf(stderr, "agentgate-verify: manifest: %v\n", manifestErr.Err)
return 1
}

result, err := receipt.VerifyChain(receipts, trustedKeys, anchor, expected)
if err != nil {
fmt.Fprintf(stderr, "agentgate-verify: %v\n", err)
return 2
Expand Down Expand Up @@ -203,7 +189,7 @@ type jsonResult struct {
Range string `json:"range,omitempty"`
}

func writeJSONResult(stdout io.Writer, result receipt.VerifyResult, rangeKind string) {
func writeJSONResult(stdout io.Writer, result receiptspec.VerifyResult, rangeKind string) {
output := jsonResult{
OK: result.OK,
TotalReceipts: result.TotalReceipts,
Expand All @@ -222,7 +208,7 @@ func writeJSONResult(stdout io.Writer, result receipt.VerifyResult, rangeKind st

// 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) {
func readSQLite(path string) ([]receiptspec.Receipt, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
Expand All @@ -239,7 +225,7 @@ func readSQLite(path string) ([]receipt.Receipt, error) {
}
defer rows.Close()

var out []receipt.Receipt
var out []receiptspec.Receipt
for rows.Next() {
r, err := receipt.ScanReceiptRow(rows)
if err != nil {
Expand All @@ -250,57 +236,15 @@ func readSQLite(path string) ([]receipt.Receipt, error) {
return out, rows.Err()
}

// readJSONL reads path (or stdin when path is "-"), dispatching each
// non-empty line by its "type" field: "key" lines accumulate into
// embeddedKeys, a "manifest" line becomes manifest (at most one is
// expected; a later one overwrites, since a well-formed export has
// exactly one), and everything else ("receipt", or no "type" field at
// all — Phase 5's original plain format) accumulates into receipts.
func readJSONL(path string) (receipts []receipt.Receipt, embeddedKeys []receipt.TrustedKey, manifest *receipt.ExportManifest, err error) {
var rdr io.Reader
// readJSONL reads path (or stdin when path is "-") with receiptspec.ReadJSONL.
func readJSONL(path string) (receiptspec.Bundle, error) {
if path == "-" {
rdr = os.Stdin
} else {
f, ferr := os.Open(path)
if ferr != nil {
return nil, nil, nil, ferr
}
defer f.Close()
rdr = f
return receiptspec.ReadJSONL(os.Stdin)
}

scanner := bufio.NewScanner(rdr)
scanner.Buffer(make([]byte, 1<<20), 1<<24)
lineNum := 0
for scanner.Scan() {
lineNum++
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
switch receipt.DetectJSONLLineType(line) {
case "manifest":
m, perr := receipt.ParseManifestLine(line)
if perr != nil {
return nil, nil, nil, fmt.Errorf("line %d: %w", lineNum, perr)
}
manifest = &m
case "key":
k, perr := receipt.ParseKeyLine(line)
if perr != nil {
return nil, nil, nil, fmt.Errorf("line %d: %w", lineNum, perr)
}
embeddedKeys = append(embeddedKeys, k)
default:
r, perr := receipt.ParseJSONLReceipt(line)
if perr != nil {
return nil, nil, nil, fmt.Errorf("line %d: %w", lineNum, perr)
}
receipts = append(receipts, r)
}
}
if serr := scanner.Err(); serr != nil {
return nil, nil, nil, serr
f, err := os.Open(path)
if err != nil {
return receiptspec.Bundle{}, err
}
return receipts, embeddedKeys, manifest, nil
defer f.Close()
return receiptspec.ReadJSONL(f)
}
81 changes: 81 additions & 0 deletions cmd/agentgate-verify/receiptspec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
Copyright 2026 Clawdlinux.
Licensed under the Apache License, Version 2.0.
*/

package main

import (
"bytes"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/Clawdlinux/agentgate/pkg/receiptspec"
)

// A second producer that only uses pkg/receiptspec must verify with this command.
func TestRunVerifiesReceiptspecChain(t *testing.T) {
priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{3}, ed25519.SeedSize))
s := receiptspec.NewEd25519Signer(priv)
c := receiptspec.NewChain(s, 0, [32]byte{})
record, err := receiptspec.HashRecord(map[string]any{"outcome": "require_approval", "record_version": 1})
if err != nil {
t.Fatal(err)
}
var receipts []receiptspec.Receipt
for i := 0; i < 5; i++ {
r, err := c.Next(receiptspec.Fields{
TimestampUnixNS: uint64(1_700_000_000_000_000_000 + i),
HumanPrincipal: "operator@cluster",
AgentKeyID: "operator",
Service: "operator",
Action: "decision:scale",
ParamsSHA256: record,
PolicyDecision: receiptspec.DecisionDeny,
StatusCode: 202,
})
if err != nil {
t.Fatal(err)
}
receipts = append(receipts, r)
}

dir := t.TempDir()
var buf bytes.Buffer
if err := receiptspec.WriteJSONL(&buf, receipts); err != nil {
t.Fatal(err)
}
chainPath := filepath.Join(dir, "chain.jsonl")
if err := os.WriteFile(chainPath, buf.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
trust, _ := json.Marshal(map[string]any{"keys": []map[string]any{{
"kid": s.KID(), "public_key_hex": hex.EncodeToString(priv.Public().(ed25519.PublicKey)), "valid_from_seq": 1,
}}})
trustPath := filepath.Join(dir, "trust.json")
if err := os.WriteFile(trustPath, trust, 0o600); err != nil {
t.Fatal(err)
}

head := receipts[4]
var stdout, stderr bytes.Buffer
code := run([]string{"--source", "jsonl", "--path", chainPath, "--trust-root", trustPath,
"--expected-head", "5:" + hex.EncodeToString(head.EntryHash[:])}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit %d, stderr %s", code, stderr.String())
}

tampered := bytes.Replace(buf.Bytes(), []byte(`"latency_ms":0`), []byte(`"latency_ms":1`), 1)
if err := os.WriteFile(chainPath, tampered, 0o600); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
if code := run([]string{"--source", "jsonl", "--path", chainPath, "--trust-root", trustPath}, &stdout, &stderr); code != 1 {
t.Fatalf("tampered exit %d, want 1", code)
}
}
56 changes: 7 additions & 49 deletions internal/receipt/encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,54 +5,12 @@ Licensed under the Apache License, Version 2.0.

package receipt

import (
"crypto/sha256"
"encoding/binary"
)

// HashDomainV1 separates AgentGate receipt hashes from other protocols and versions.
const HashDomainV1 = "agentgate.receipt.hash.v1"

// CanonicalHashInput returns the validated v1 entry-hash preimage.
func CanonicalHashInput(receipt Receipt) ([]byte, error) {
receipt = snapshot(receipt)
if err := Validate(receipt); err != nil {
return nil, err
}
import "github.com/Clawdlinux/agentgate/pkg/receiptspec"

encoded := make([]byte, 0, 3110)
encoded = append(encoded, HashDomainV1...)
encoded = append(encoded, 0)
encoded = binary.LittleEndian.AppendUint64(encoded, receipt.Seq)
encoded = binary.LittleEndian.AppendUint64(encoded, receipt.TimestampUnixNS)
encoded = appendLP(encoded, receipt.HumanPrincipal)
encoded = appendLP(encoded, receipt.AgentKeyID)
encoded = binary.LittleEndian.AppendUint32(encoded, uint32(len(receipt.DelegationChain)))
for _, element := range receipt.DelegationChain {
encoded = appendLP(encoded, element)
}
encoded = appendLP(encoded, receipt.Service)
encoded = appendLP(encoded, receipt.Action)
encoded = append(encoded, receipt.ParamsSHA256[:]...)
encoded = appendLP(encoded, receipt.PolicyDecision)
encoded = binary.LittleEndian.AppendUint32(encoded, uint32(int32(receipt.StatusCode)))
encoded = binary.LittleEndian.AppendUint64(encoded, uint64(receipt.LatencyMS))
encoded = appendLP(encoded, receipt.Error)
encoded = append(encoded, receipt.PrevHash[:]...)
encoded = appendLP(encoded, receipt.SignerKID)
return encoded, nil
}
// HashDomainV1 is receiptspec.HashDomainV1.
const HashDomainV1 = receiptspec.HashDomainV1

// ComputeEntryHash returns SHA-256 over the complete canonical hash input.
func ComputeEntryHash(receipt Receipt) ([32]byte, error) {
encoded, err := CanonicalHashInput(receipt)
if err != nil {
return [32]byte{}, err
}
return sha256.Sum256(encoded), nil
}

func appendLP(destination []byte, value string) []byte {
destination = binary.LittleEndian.AppendUint32(destination, uint32(len(value)))
return append(destination, value...)
}
var (
CanonicalHashInput = receiptspec.CanonicalHashInput
ComputeEntryHash = receiptspec.ComputeEntryHash
)
Loading
Loading