Skip to content

Commit 6581227

Browse files
OpsKernclaude
andcommitted
feat(writ): verify existing chain on New() — refuse corrupt extend (CTO C-5a/b)
- Add Config.AllowCorruptChainRecovery bool (default false) - Add ErrCorruptChain sentinel error - openChainVerify() called in NewWithContext after store open: reads entries, runs verifyChain; returns ErrCorruptChain (wrapping the verification error) if chain is corrupt and recovery not allowed - findLastValidHash() walks chain to locate last intact entry - buildSegmentBoundaryEntry() scaffolded for sub-task 2 (C-5c) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5ab760d commit 6581227

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

chain.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,98 @@ func computeEntryHash(store AuditStore, entry ChainEntry) (ChainEntry, error) {
238238
return entry, nil
239239
}
240240

241+
// openChainVerify verifies the existing chain on New(). If the chain is corrupt
242+
// and AllowCorruptChainRecovery is false, it returns ErrCorruptChain.
243+
// If recovery is allowed, it writes a ChainSegmentBoundary entry and proceeds.
244+
func openChainVerify(store AuditStore, cfg Config) error {
245+
entries, err := store.ReadAll()
246+
if err != nil {
247+
return fmt.Errorf("writ: read chain for verification: %w", err)
248+
}
249+
if len(entries) == 0 {
250+
return nil
251+
}
252+
verifyErr := verifyChain(entries)
253+
if verifyErr == nil {
254+
return nil
255+
}
256+
if !cfg.AllowCorruptChainRecovery {
257+
return fmt.Errorf("%w: %v", ErrCorruptChain, verifyErr)
258+
}
259+
lastValid := findLastValidHash(entries)
260+
reason := fmt.Sprintf("recovery at %s: %v", time.Now().UTC().Format(time.RFC3339), verifyErr)
261+
boundary, err := buildSegmentBoundaryEntry(lastValid, reason)
262+
if err != nil {
263+
return fmt.Errorf("writ: build recovery boundary: %w", err)
264+
}
265+
return store.Append(boundary)
266+
}
267+
268+
// findLastValidHash walks entries and returns the hash of the last entry that
269+
// verifies correctly, or the genesis hash if none verify.
270+
func findLastValidHash(entries []ChainEntry) string {
271+
prevHash := inaudit.PrevHashFor(nil)
272+
for _, e := range entries {
273+
ie := inaudit.Entry{
274+
ID: e.ID,
275+
PrevHash: e.PrevHash,
276+
Hash: e.Hash,
277+
EventType: e.EventType,
278+
ActionType: e.ActionType,
279+
Actor: e.Actor,
280+
CallerID: e.CallerID,
281+
InputHash: e.InputHash,
282+
OutputHash: e.OutputHash,
283+
Result: e.Result,
284+
HookdTraceID: e.HookdTraceID,
285+
Allowed: e.Allowed,
286+
DenialReason: e.DenialReason,
287+
Timestamp: timestampString(e.Timestamp),
288+
}
289+
if ie.PrevHash != prevHash {
290+
break
291+
}
292+
want, err := inaudit.ComputeHash(ie)
293+
if err != nil || ie.Hash != want {
294+
break
295+
}
296+
prevHash = ie.Hash
297+
}
298+
return prevHash
299+
}
300+
301+
// buildSegmentBoundaryEntry creates a ChainSegmentBoundary entry that anchors
302+
// the start of a new chain segment after corruption recovery.
303+
func buildSegmentBoundaryEntry(lastValidHash, reason string) (ChainEntry, error) {
304+
id := newAuditID()
305+
ts := time.Now().UTC()
306+
307+
internal := inaudit.Entry{
308+
ID: id,
309+
PrevHash: lastValidHash,
310+
EventType: "chain_segment_boundary",
311+
Allowed: true,
312+
Timestamp: ts.Format(time.RFC3339Nano),
313+
Metadata: map[string]string{"type": "RECOVERY", "reason": reason},
314+
}
315+
316+
hash, err := inaudit.ComputeHash(internal)
317+
if err != nil {
318+
return ChainEntry{}, fmt.Errorf("compute boundary hash: %w", err)
319+
}
320+
internal.Hash = hash
321+
322+
return ChainEntry{
323+
ID: internal.ID,
324+
PrevHash: internal.PrevHash,
325+
Hash: internal.Hash,
326+
EventType: internal.EventType,
327+
Allowed: internal.Allowed,
328+
Timestamp: ts,
329+
Metadata: internal.Metadata,
330+
}, nil
331+
}
332+
241333
// verifyChain is the internal entry point for Verify().
242334
func verifyChain(entries []ChainEntry) error {
243335
internalEntries := make([]inaudit.Entry, len(entries))

writ.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package writ
77

88
import (
99
"context"
10+
"errors"
1011
"fmt"
1112
"time"
1213

@@ -42,8 +43,17 @@ type Config struct {
4243
// EagerReload enables a goroutine-based policy watcher.
4344
// Default (false) uses lazy reload: policy is re-read when mtime changes.
4445
EagerReload bool
46+
47+
// AllowCorruptChainRecovery, when true, permits New() to open a chain that
48+
// fails Merkle verification. A ChainSegmentBoundary entry is written
49+
// recording the recovery event. Default false: corrupt chain → ErrCorruptChain.
50+
AllowCorruptChainRecovery bool
4551
}
4652

53+
// ErrCorruptChain is returned by New() when the existing chain fails Merkle
54+
// verification and Config.AllowCorruptChainRecovery is false.
55+
var ErrCorruptChain = errors.New("writ: existing chain fails Merkle verification")
56+
4757
// Client wraps an anthropic.Client with a pre-call codification gate and
4858
// post-call Merkle audit chain write. Construct with writ.New().
4959
type Client struct {
@@ -81,6 +91,10 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) {
8191
}
8292
}
8393

94+
if err := openChainVerify(store, cfg); err != nil {
95+
return nil, err
96+
}
97+
8498
g, err := newGate(ctx, cfg.PolicyPath, cfg.EagerReload)
8599
if err != nil {
86100
return nil, fmt.Errorf("writ: init gate: %w", err)

0 commit comments

Comments
 (0)