Skip to content

Commit 5ab760d

Browse files
OpsKernclaude
andcommitted
fix(chain): compute Merkle hash on gate decision entries; fix verifyChain timestamp handling (Spike B)
Two pre-existing bugs found and fixed during Spike B end-to-end validation: 1. Gate decision entries (written by messages.go before calling the Anthropic API) were appended without PrevHash or Hash set. computeEntryHash() now links and hashes them before Append, making the full chain verifiable including denied calls. 2. verifyChain() and lastHash() type-asserted ChainEntry.Timestamp as time.Time, which silently fails after JSONL read-back (JSON unmarshal produces string, not time.Time). timestampString() now normalises both cases; hash computation is consistent across the in-memory and JSONL paths. Adds spike_b_test.go (build tag: integration): end-to-end test that validates DENY path, explicit Audit() call, and writ.Verify() against /tmp/writ-chain.jsonl. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0e7d58f commit 5ab760d

3 files changed

Lines changed: 133 additions & 10 deletions

File tree

chain.go

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,26 +184,64 @@ func lastHash(store AuditStore) (string, error) {
184184
}
185185
internalEntries := make([]inaudit.Entry, len(entries))
186186
for i, e := range entries {
187-
ts := ""
188-
if t, ok := e.Timestamp.(time.Time); ok {
189-
ts = t.Format(time.RFC3339Nano)
190-
}
191187
internalEntries[i] = inaudit.Entry{
192188
Hash: e.Hash,
193-
Timestamp: ts,
189+
Timestamp: timestampString(e.Timestamp),
194190
}
195191
}
196192
return inaudit.PrevHashFor(internalEntries), nil
197193
}
198194

195+
// timestampString normalises a ChainEntry.Timestamp (interface{}) to the RFC3339Nano
196+
// string used in Merkle hash computation. Handles both time.Time (in-memory path) and
197+
// string (JSONL read-back path).
198+
func timestampString(v interface{}) string {
199+
switch t := v.(type) {
200+
case time.Time:
201+
return t.Format(time.RFC3339Nano)
202+
case string:
203+
return t
204+
default:
205+
return ""
206+
}
207+
}
208+
209+
// computeEntryHash sets PrevHash to the last chain hash and computes the Merkle
210+
// hash for entry. Must be called on gate decision entries before Append, since
211+
// those are constructed without hash fields by the gate evaluator.
212+
func computeEntryHash(store AuditStore, entry ChainEntry) (ChainEntry, error) {
213+
prevHash, err := lastHash(store)
214+
if err != nil {
215+
return ChainEntry{}, fmt.Errorf("read prev hash: %w", err)
216+
}
217+
entry.PrevHash = prevHash
218+
219+
internal := inaudit.Entry{
220+
PrevHash: entry.PrevHash,
221+
EventType: entry.EventType,
222+
ActionType: entry.ActionType,
223+
Actor: entry.Actor,
224+
CallerID: entry.CallerID,
225+
InputHash: entry.InputHash,
226+
OutputHash: entry.OutputHash,
227+
Result: entry.Result,
228+
HookdTraceID: entry.HookdTraceID,
229+
Allowed: entry.Allowed,
230+
DenialReason: entry.DenialReason,
231+
Timestamp: timestampString(entry.Timestamp),
232+
}
233+
hash, err := inaudit.ComputeHash(internal)
234+
if err != nil {
235+
return ChainEntry{}, fmt.Errorf("compute entry hash: %w", err)
236+
}
237+
entry.Hash = hash
238+
return entry, nil
239+
}
240+
199241
// verifyChain is the internal entry point for Verify().
200242
func verifyChain(entries []ChainEntry) error {
201243
internalEntries := make([]inaudit.Entry, len(entries))
202244
for i, e := range entries {
203-
ts := ""
204-
if t, ok := e.Timestamp.(time.Time); ok {
205-
ts = t.Format(time.RFC3339Nano)
206-
}
207245
internalEntries[i] = inaudit.Entry{
208246
ID: e.ID,
209247
PrevHash: e.PrevHash,
@@ -218,7 +256,7 @@ func verifyChain(entries []ChainEntry) error {
218256
HookdTraceID: e.HookdTraceID,
219257
Allowed: e.Allowed,
220258
DenialReason: e.DenialReason,
221-
Timestamp: ts,
259+
Timestamp: timestampString(e.Timestamp),
222260
}
223261
}
224262
return inaudit.Verify(internalEntries)

messages.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ func (s *MessagesService) New(ctx context.Context, params anthropic.MessageNewPa
2424
}
2525

2626
entry.Timestamp = time.Now().UTC()
27+
entry, err = computeEntryHash(s.wc.chain, entry)
28+
if err != nil {
29+
return nil, fmt.Errorf("writ: hash pre-call audit entry: %w", err)
30+
}
2731
if err := s.wc.chain.Append(entry); err != nil {
2832
return nil, fmt.Errorf("writ: write pre-call audit entry: %w", err)
2933
}
@@ -58,6 +62,10 @@ func (s *MessagesService) NewStreaming(ctx context.Context, params anthropic.Mes
5862

5963
entry.EventType = "llm_call_streaming_started"
6064
entry.Timestamp = time.Now().UTC()
65+
entry, err = computeEntryHash(s.wc.chain, entry)
66+
if err != nil {
67+
return nil, fmt.Errorf("writ: hash streaming-started audit entry: %w", err)
68+
}
6169
if err := s.wc.chain.Append(entry); err != nil {
6270
return nil, fmt.Errorf("writ: write streaming-started audit entry: %w", err)
6371
}

spike_b_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//go:build integration
2+
3+
package writ_test
4+
5+
// TestSpikeB_EndToEnd is the Spike B acceptance test (writ-v0-spikes-2026-05-05.md).
6+
// It validates writ.New() ergonomics end-to-end without an Anthropic API key:
7+
//
8+
// - HARD_BLOCK: deny-all policy → DenialError returned, no Anthropic API call,
9+
// pre-call audit entry written with Merkle link intact.
10+
// - Explicit audit: writ.Audit() writes a tool_use entry to the same chain.
11+
// - Verification: writ.Verify confirms all Merkle hash links are intact.
12+
//
13+
// Produces /tmp/writ-chain.jsonl as a side effect for acceptance-criteria checks.
14+
15+
import (
16+
"context"
17+
"errors"
18+
"os"
19+
"path/filepath"
20+
"testing"
21+
"time"
22+
23+
"github.com/anthropics/anthropic-sdk-go"
24+
"github.com/opskernel-io/writ"
25+
)
26+
27+
func TestSpikeB_EndToEnd(t *testing.T) {
28+
policyDir := t.TempDir()
29+
const denyPolicy = `package writ.gate
30+
import rego.v1
31+
default allow := false
32+
default tier := 0
33+
default denial_reason := "spike-b deny test"`
34+
if err := os.WriteFile(filepath.Join(policyDir, "deny_all.rego"), []byte(denyPolicy), 0o600); err != nil {
35+
t.Fatal(err)
36+
}
37+
38+
chainPath := "/tmp/writ-chain.jsonl"
39+
_ = os.Remove(chainPath)
40+
41+
c, err := writ.New(writ.Config{
42+
PolicyPath: policyDir,
43+
AuditPath: chainPath,
44+
CallerID: "spike-b-test",
45+
})
46+
if err != nil {
47+
t.Fatalf("writ.New: %v", err)
48+
}
49+
50+
// HARD_BLOCK: deny policy → DenialError, Messages.New makes no Anthropic API call.
51+
_, callErr := c.Messages.New(context.Background(), anthropic.MessageNewParams{})
52+
if callErr == nil {
53+
t.Fatal("want DenialError from deny policy, got nil")
54+
}
55+
var denialErr *writ.DenialError
56+
if !errors.As(callErr, &denialErr) {
57+
t.Fatalf("want *writ.DenialError, got %T: %v", callErr, callErr)
58+
}
59+
t.Logf("HARD_BLOCK ok: reason=%q audit_id=%s", denialErr.Reason, denialErr.AuditID)
60+
61+
// Explicit audit: tool_use event written via writ.Audit().
62+
if err := c.Audit(writ.AuditEvent{
63+
EventType: "tool_use",
64+
ActionType: "read_file",
65+
InputHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
66+
Result: "success",
67+
Timestamp: time.Now().UTC(),
68+
}); err != nil {
69+
t.Fatalf("writ.Audit: %v", err)
70+
}
71+
72+
// Chain verification: all Merkle hash links must be intact.
73+
if err := writ.Verify(chainPath); err != nil {
74+
t.Fatalf("writ.Verify: %v", err)
75+
}
76+
t.Logf("chain at %s verified OK", chainPath)
77+
}

0 commit comments

Comments
 (0)