Skip to content

Commit d6a788a

Browse files
OpsKernclaude
andcommitted
feat(compliance): PR 2 — restart continuity via SessionID
Add SessionID to Config, ChainEntry, and Merkle hash so process-restart boundaries are detectable in the audit chain. - Config.SessionID written into every chain entry - NewWithContext warns (via Client.Warnings()) when chain's last entry has a different non-empty session_id, flagging a cross-restart chain - VerifyFull() returns VerifyResult with Valid, EntryCount, RootHash, and SessionGaps — structured session-boundary detection without breaking existing Verify() API - SessionID uses omitempty in HashContent so pre-v0.2 entries remain verifiable (no hash drift on upgrade) - Fix chainTimestampStr helper: JSONL round-trip deserialises Timestamp as string, not time.Time; verifyChain was silently using "" causing hash mismatch for file-backed stores gosec: 0 issues (1 nosec); go test -race: PASS; govulncheck: clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 63fe1f7 commit d6a788a

6 files changed

Lines changed: 252 additions & 2 deletions

File tree

chain.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func newAuditID() string {
1515

1616
// buildChainEntry creates a Merkle-linked ChainEntry from an AuditEvent.
1717
// Reads the last entry from store to get the previous hash.
18-
func buildChainEntry(store AuditStore, event AuditEvent, callerID, hookdTraceID string) (ChainEntry, error) {
18+
func buildChainEntry(store AuditStore, event AuditEvent, callerID, hookdTraceID, sessionID string) (ChainEntry, error) {
1919
prev, err := lastHash(store)
2020
if err != nil {
2121
return ChainEntry{}, err
@@ -52,6 +52,7 @@ func buildChainEntry(store AuditStore, event AuditEvent, callerID, hookdTraceID
5252
ActionType: event.ActionType,
5353
Actor: actor,
5454
CallerID: event.CallerID,
55+
SessionID: sessionID,
5556
InputHash: event.InputHash,
5657
OutputHash: event.OutputHash,
5758
Result: result,
@@ -75,6 +76,7 @@ func buildChainEntry(store AuditStore, event AuditEvent, callerID, hookdTraceID
7576
ActionType: internal.ActionType,
7677
Actor: internal.Actor,
7778
CallerID: internal.CallerID,
79+
SessionID: internal.SessionID,
7880
InputHash: internal.InputHash,
7981
OutputHash: internal.OutputHash,
8082
Result: internal.Result,
@@ -107,6 +109,7 @@ func buildPostCallEntry(store AuditStore, preEntry ChainEntry, resp *anthropic.M
107109
PrevHash: prev,
108110
EventType: eventType,
109111
CallerID: cfg.CallerID,
112+
SessionID: cfg.SessionID,
110113
OutputHash: outputHash,
111114
HookdTraceID: cfg.HookdTraceID,
112115
Allowed: true,
@@ -126,6 +129,7 @@ func buildPostCallEntry(store AuditStore, preEntry ChainEntry, resp *anthropic.M
126129
Hash: internal.Hash,
127130
EventType: internal.EventType,
128131
CallerID: internal.CallerID,
132+
SessionID: internal.SessionID,
129133
OutputHash: internal.OutputHash,
130134
HookdTraceID: internal.HookdTraceID,
131135
Allowed: internal.Allowed,
@@ -152,6 +156,7 @@ func buildStreamCompleteEntry(store AuditStore, startEntry ChainEntry, streamErr
152156
PrevHash: prev,
153157
EventType: eventType,
154158
CallerID: cfg.CallerID,
159+
SessionID: cfg.SessionID,
155160
HookdTraceID: cfg.HookdTraceID,
156161
Allowed: true,
157162
Timestamp: ts.Format(time.RFC3339Nano),
@@ -170,6 +175,7 @@ func buildStreamCompleteEntry(store AuditStore, startEntry ChainEntry, streamErr
170175
Hash: internal.Hash,
171176
EventType: internal.EventType,
172177
CallerID: internal.CallerID,
178+
SessionID: internal.SessionID,
173179
HookdTraceID: internal.HookdTraceID,
174180
Allowed: internal.Allowed,
175181
Timestamp: ts,
@@ -222,6 +228,7 @@ func computeEntryHash(store AuditStore, entry ChainEntry) (ChainEntry, error) {
222228
ActionType: entry.ActionType,
223229
Actor: entry.Actor,
224230
CallerID: entry.CallerID,
231+
SessionID: entry.SessionID,
225232
InputHash: entry.InputHash,
226233
OutputHash: entry.OutputHash,
227234
Result: entry.Result,
@@ -278,6 +285,7 @@ func findLastValidHash(entries []ChainEntry) string {
278285
ActionType: e.ActionType,
279286
Actor: e.Actor,
280287
CallerID: e.CallerID,
288+
SessionID: e.SessionID,
281289
InputHash: e.InputHash,
282290
OutputHash: e.OutputHash,
283291
Result: e.Result,
@@ -330,6 +338,7 @@ func buildSegmentBoundaryEntry(lastValidHash, reason string) (ChainEntry, error)
330338
}, nil
331339
}
332340

341+
333342
// verifyChain is the internal entry point for Verify().
334343
func verifyChain(entries []ChainEntry) error {
335344
internalEntries := make([]inaudit.Entry, len(entries))
@@ -342,6 +351,7 @@ func verifyChain(entries []ChainEntry) error {
342351
ActionType: e.ActionType,
343352
Actor: e.Actor,
344353
CallerID: e.CallerID,
354+
SessionID: e.SessionID,
345355
InputHash: e.InputHash,
346356
OutputHash: e.OutputHash,
347357
Result: e.Result,

gate.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func (g *gateWrapper) evaluate(ctx context.Context, params anthropic.MessageNewP
4848
ID: auditID,
4949
EventType: "llm_call",
5050
CallerID: cfg.CallerID,
51+
SessionID: cfg.SessionID,
5152
HookdTraceID: cfg.HookdTraceID,
5253
Allowed: result.Allowed,
5354
DenialReason: result.DenialReason,

internal/audit/chain.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Entry struct {
2121
ActionType string `json:"action_type,omitempty"`
2222
Actor string `json:"actor,omitempty"`
2323
CallerID string `json:"caller_id,omitempty"`
24+
SessionID string `json:"session_id,omitempty"`
2425
InputHash string `json:"input_hash,omitempty"`
2526
OutputHash string `json:"output_hash,omitempty"`
2627
Result string `json:"result,omitempty"`
@@ -34,12 +35,15 @@ type Entry struct {
3435

3536
// HashContent is the subset of fields included in the Merkle hash.
3637
// Excludes Hash itself (computed from this) and Metadata (advisory).
38+
// SessionID uses omitempty so pre-v0.2 entries (without session_id) remain
39+
// verifiable — an absent field hashes identically to an absent JSON key.
3740
type HashContent struct {
3841
PrevHash string `json:"prev_hash"`
3942
EventType string `json:"event_type"`
4043
ActionType string `json:"action_type,omitempty"`
4144
Actor string `json:"actor,omitempty"`
4245
CallerID string `json:"caller_id,omitempty"`
46+
SessionID string `json:"session_id,omitempty"`
4347
InputHash string `json:"input_hash,omitempty"`
4448
OutputHash string `json:"output_hash,omitempty"`
4549
Result string `json:"result,omitempty"`
@@ -56,6 +60,7 @@ func ComputeHash(e Entry) (string, error) {
5660
EventType: e.EventType,
5761
ActionType: e.ActionType,
5862
CallerID: e.CallerID,
63+
SessionID: e.SessionID,
5964
InputHash: e.InputHash,
6065
OutputHash: e.OutputHash,
6166
HookdTraceID: e.HookdTraceID,

store.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type ChainEntry struct {
2626
ActionType string `json:"action_type,omitempty"`
2727
Actor string `json:"actor,omitempty"`
2828
CallerID string `json:"caller_id,omitempty"`
29+
SessionID string `json:"session_id,omitempty"`
2930
InputHash string `json:"input_hash,omitempty"`
3031
OutputHash string `json:"output_hash,omitempty"`
3132
Result string `json:"result,omitempty"`

writ.go

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ type Config struct {
4848
// fails Merkle verification. A ChainSegmentBoundary entry is written
4949
// recording the recovery event. Default false: corrupt chain → ErrCorruptChain.
5050
AllowCorruptChainRecovery bool
51+
52+
// SessionID is a stable identifier for the current process run
53+
// (e.g. a UUID generated at startup). Written into each chain entry.
54+
// On writ.New(), if the chain's last entry has a different non-empty
55+
// SessionID, a warning is added to Client.Warnings() to flag the
56+
// cross-session boundary for human review.
57+
SessionID string
5158
}
5259

5360
// ErrCorruptChain is returned by New() when the existing chain fails Merkle
@@ -62,6 +69,7 @@ type Client struct {
6269
cfg Config
6370
gater *gateWrapper
6471
chain AuditStore
72+
warnings []string // non-fatal init warnings (e.g. session ID mismatch)
6573
}
6674

6775
// New constructs a writ.Client with lazy OPA policy reload.
@@ -108,6 +116,20 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) {
108116
chain: store,
109117
}
110118
c.Messages = &MessagesService{wc: c}
119+
120+
// Warn on session ID mismatch so operators know the chain crosses a restart.
121+
if cfg.SessionID != "" {
122+
if entries, readErr := store.ReadAll(); readErr == nil && len(entries) > 0 {
123+
last := entries[len(entries)-1]
124+
if last.SessionID != "" && last.SessionID != cfg.SessionID {
125+
c.warnings = append(c.warnings, fmt.Sprintf(
126+
"session ID mismatch: last chain entry has session_id=%q, current session is %q — chain spans a process restart",
127+
last.SessionID, cfg.SessionID,
128+
))
129+
}
130+
}
131+
}
132+
111133
return c, nil
112134
}
113135

@@ -188,14 +210,84 @@ type AuditEvent struct {
188210
Metadata map[string]string
189211
}
190212

213+
// Warnings returns non-fatal issues detected at construction time.
214+
// Currently populated when Config.SessionID differs from the last chain
215+
// entry's session_id, indicating the chain spans a process restart.
216+
func (c *Client) Warnings() []string {
217+
return c.warnings
218+
}
219+
220+
// VerifyResult is the output of Client.VerifyFull.
221+
type VerifyResult struct {
222+
Valid bool
223+
EntryCount int
224+
FirstBreak *ChainEntry // nil if Valid is true
225+
RootHash string // hash of the last entry; empty if chain is empty
226+
SessionGaps []SessionGap
227+
}
228+
229+
// SessionGap describes a point in the chain where the session_id changed,
230+
// indicating a process restart boundary.
231+
type SessionGap struct {
232+
AfterEntryIndex int // index of the last entry with PrevSessionID
233+
PrevSessionID string
234+
NextSessionID string
235+
}
236+
237+
// VerifyFull verifies Merkle hash integrity and reports SessionID gaps.
238+
// Unlike the package-level Verify, this returns structured results including
239+
// chain continuity information across process restarts.
240+
func (c *Client) VerifyFull() (*VerifyResult, error) {
241+
entries, err := c.chain.ReadAll()
242+
if err != nil {
243+
return nil, fmt.Errorf("writ.VerifyFull: read chain: %w", err)
244+
}
245+
result := &VerifyResult{EntryCount: len(entries)}
246+
if err := verifyChain(entries); err != nil {
247+
result.Valid = false
248+
result.FirstBreak = firstBrokenEntry(entries)
249+
} else {
250+
result.Valid = true
251+
if len(entries) > 0 {
252+
result.RootHash = entries[len(entries)-1].Hash
253+
}
254+
}
255+
for i := 1; i < len(entries); i++ {
256+
prev, curr := entries[i-1], entries[i]
257+
if prev.SessionID != "" && curr.SessionID != "" && curr.SessionID != prev.SessionID {
258+
result.SessionGaps = append(result.SessionGaps, SessionGap{
259+
AfterEntryIndex: i - 1,
260+
PrevSessionID: prev.SessionID,
261+
NextSessionID: curr.SessionID,
262+
})
263+
}
264+
}
265+
return result, nil
266+
}
267+
268+
// firstBrokenEntry returns the first ChainEntry whose hash link is broken,
269+
// or nil if the chain is intact.
270+
func firstBrokenEntry(entries []ChainEntry) *ChainEntry {
271+
for i := range entries {
272+
copy := entries[i]
273+
if i == len(entries)-1 {
274+
return &copy
275+
}
276+
if entries[i+1].PrevHash != entries[i].Hash {
277+
return &copy
278+
}
279+
}
280+
return nil
281+
}
282+
191283
// Audit writes an explicit event to the writ chain. Use for tool use events
192284
// (file read, shell exec, web fetch) that require Article 12 granularity.
193285
// The chain entry includes a Merkle link to the previous entry.
194286
func (c *Client) Audit(event AuditEvent) error {
195287
if event.Timestamp.IsZero() {
196288
event.Timestamp = time.Now().UTC()
197289
}
198-
entry, err := buildChainEntry(c.chain, event, c.cfg.CallerID, c.cfg.HookdTraceID)
290+
entry, err := buildChainEntry(c.chain, event, c.cfg.CallerID, c.cfg.HookdTraceID, c.cfg.SessionID)
199291
if err != nil {
200292
return fmt.Errorf("writ.Audit: build entry: %w", err)
201293
}

0 commit comments

Comments
 (0)