M1: hash-chained session audit log

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:38:40 -04:00
parent a31ce702fe
commit d780643015
2 changed files with 218 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
package store
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"strings"
)
const genesisHash = "0000000000000000000000000000000000000000000000000000000000000000"
// BucketTotal is per-(class, scrubbed-title) accumulated time in a session.
type BucketTotal struct {
Class string `json:"class"`
Title string `json:"title"` // scrubbed
Seconds int64 `json:"seconds"`
}
// SessionSummary is one permanent, hash-chained record of a finished session.
type SessionSummary struct {
Seq int `json:"seq"`
PrevHash string `json:"prev_hash"`
SessionID string `json:"session_id"`
NextAction string `json:"next_action"`
SuccessCond string `json:"success_condition"`
Outcome string `json:"outcome"` // "completed" | "expired"
StartedUnix int64 `json:"started_unix"`
EndedUnix int64 `json:"ended_unix"`
SwitchCount int `json:"switch_count"`
Buckets []BucketTotal `json:"buckets"` // sorted desc by seconds
Hash string `json:"hash"`
}
// computeHash returns SHA-256(prevHash || canonicalJSON(s with Hash zeroed)).
// encoding/json emits struct fields in declaration order, so this is stable.
func computeHash(s SessionSummary) (string, error) {
s.Hash = ""
canonical, err := json.Marshal(s)
if err != nil {
return "", err
}
h := sha256.New()
h.Write([]byte(s.PrevHash))
h.Write(canonical)
return hex.EncodeToString(h.Sum(nil)), nil
}
func readSummaries(path string) ([]SessionSummary, error) {
f, err := os.Open(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
var out []SessionSummary
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var s SessionSummary
if err := json.Unmarshal([]byte(line), &s); err != nil {
return nil, err
}
out = append(out, s)
}
return out, sc.Err()
}
// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns
// the next contiguous Seq, computes its Hash, and appends one JSON line with an
// fsync.
func AppendSession(path string, s SessionSummary) error {
existing, err := readSummaries(path)
if err != nil {
return err
}
if len(existing) == 0 {
s.Seq = 1
s.PrevHash = genesisHash
} else {
last := existing[len(existing)-1]
s.Seq = last.Seq + 1
s.PrevHash = last.Hash
}
hash, err := computeHash(s)
if err != nil {
return err
}
s.Hash = hash
line, err := json.Marshal(s)
if err != nil {
return err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Write(append(line, '\n')); err != nil {
return err
}
return f.Sync()
}
// VerifyChain re-walks every line, recomputing hashes and confirming each
// prev_hash links to the prior hash and Seq is contiguous from 1. It returns an
// error naming the first broken line (1-based); nil if intact or empty.
func VerifyChain(path string) error {
summaries, err := readSummaries(path)
if err != nil {
return err
}
prev := genesisHash
for i, s := range summaries {
lineNo := i + 1
if s.Seq != lineNo {
return fmt.Errorf("audit chain broken at line %d: seq %d, want %d", lineNo, s.Seq, lineNo)
}
if s.PrevHash != prev {
return fmt.Errorf("audit chain broken at line %d: prev_hash mismatch", lineNo)
}
want, err := computeHash(s)
if err != nil {
return err
}
if want != s.Hash {
return fmt.Errorf("audit chain broken at line %d: hash mismatch", lineNo)
}
prev = s.Hash
}
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"os"
"path/filepath"
"strings"
"testing"
)
func auditFixture(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "audit.jsonl")
}
func sampleSummary(id string) SessionSummary {
return SessionSummary{
SessionID: id,
NextAction: "write plan",
SuccessCond: "plan committed",
Outcome: "completed",
StartedUnix: 1000,
EndedUnix: 2000,
SwitchCount: 3,
Buckets: []BucketTotal{
{Class: "code", Title: "antidrift", Seconds: 540},
{Class: "firefox", Title: "docs", Seconds: 120},
},
}
}
func TestAppendSessionChainsAndVerifies(t *testing.T) {
path := auditFixture(t)
for i := 0; i < 3; i++ {
if err := AppendSession(path, sampleSummary("session-"+string(rune('a'+i)))); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
if err := VerifyChain(path); err != nil {
t.Fatalf("chain should verify: %v", err)
}
}
func TestGenesisPrevHashIsZeros(t *testing.T) {
path := auditFixture(t)
if err := AppendSession(path, sampleSummary("session-a")); err != nil {
t.Fatalf("append: %v", err)
}
data, _ := os.ReadFile(path)
if !strings.Contains(string(data), strings.Repeat("0", 64)) {
t.Fatalf("genesis prev_hash should be 64 zeros, got: %s", data)
}
}
func TestVerifyEmptyChain(t *testing.T) {
if err := VerifyChain(auditFixture(t)); err != nil {
t.Fatalf("empty/missing chain should verify, got %v", err)
}
}
func TestTamperDetected(t *testing.T) {
path := auditFixture(t)
_ = AppendSession(path, sampleSummary("session-a"))
_ = AppendSession(path, sampleSummary("session-b"))
_ = AppendSession(path, sampleSummary("session-c"))
// Corrupt the middle line's switch_count.
data, _ := os.ReadFile(path)
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
lines[1] = strings.Replace(lines[1], `"switch_count":3`, `"switch_count":99`, 1)
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatalf("rewrite: %v", err)
}
err := VerifyChain(path)
if err == nil || !strings.Contains(err.Error(), "line 2") {
t.Fatalf("tamper on line 2 should be reported, got %v", err)
}
}