Skip to content

Commit aaaffd3

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 b125cb0 commit aaaffd3

6 files changed

Lines changed: 264 additions & 12 deletions

File tree

chain.go

Lines changed: 23 additions & 11 deletions
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,
@@ -184,26 +190,31 @@ func lastHash(store AuditStore) (string, error) {
184190
}
185191
internalEntries := make([]inaudit.Entry, len(entries))
186192
for i, e := range entries {
187-
ts := ""
188-
if t, ok := e.Timestamp.(time.Time); ok {
189-
ts = t.Format(time.RFC3339Nano)
190-
}
191193
internalEntries[i] = inaudit.Entry{
192194
Hash: e.Hash,
193-
Timestamp: ts,
195+
Timestamp: chainTimestampStr(e.Timestamp),
194196
}
195197
}
196198
return inaudit.PrevHashFor(internalEntries), nil
197199
}
198200

201+
// chainTimestampStr normalises the Timestamp interface{} on ChainEntry.
202+
// In-memory entries carry time.Time; JSONL-deserialized entries carry string.
203+
func chainTimestampStr(v interface{}) string {
204+
switch t := v.(type) {
205+
case time.Time:
206+
return t.Format(time.RFC3339Nano)
207+
case string:
208+
return t
209+
default:
210+
return ""
211+
}
212+
}
213+
199214
// verifyChain is the internal entry point for Verify().
200215
func verifyChain(entries []ChainEntry) error {
201216
internalEntries := make([]inaudit.Entry, len(entries))
202217
for i, e := range entries {
203-
ts := ""
204-
if t, ok := e.Timestamp.(time.Time); ok {
205-
ts = t.Format(time.RFC3339Nano)
206-
}
207218
internalEntries[i] = inaudit.Entry{
208219
ID: e.ID,
209220
PrevHash: e.PrevHash,
@@ -212,13 +223,14 @@ func verifyChain(entries []ChainEntry) error {
212223
ActionType: e.ActionType,
213224
Actor: e.Actor,
214225
CallerID: e.CallerID,
226+
SessionID: e.SessionID,
215227
InputHash: e.InputHash,
216228
OutputHash: e.OutputHash,
217229
Result: e.Result,
218230
HookdTraceID: e.HookdTraceID,
219231
Allowed: e.Allowed,
220232
DenialReason: e.DenialReason,
221-
Timestamp: ts,
233+
Timestamp: chainTimestampStr(e.Timestamp),
222234
}
223235
}
224236
return inaudit.Verify(internalEntries)

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
@@ -42,6 +42,13 @@ type Config struct {
4242
// EagerReload enables a goroutine-based policy watcher.
4343
// Default (false) uses lazy reload: policy is re-read when mtime changes.
4444
EagerReload bool
45+
46+
// SessionID is a stable identifier for the current process run
47+
// (e.g. a UUID generated at startup). Written into each chain entry.
48+
// On writ.New(), if the chain's last entry has a different non-empty
49+
// SessionID, a warning is added to Client.Warnings() to flag the
50+
// cross-session boundary for human review.
51+
SessionID string
4552
}
4653

4754
// Client wraps an anthropic.Client with a pre-call codification gate and
@@ -52,6 +59,7 @@ type Client struct {
5259
cfg Config
5360
gater *gateWrapper
5461
chain AuditStore
62+
warnings []string // non-fatal init warnings (e.g. session ID mismatch)
5563
}
5664

5765
// New constructs a writ.Client with lazy OPA policy reload.
@@ -94,6 +102,20 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) {
94102
chain: store,
95103
}
96104
c.Messages = &MessagesService{wc: c}
105+
106+
// Warn on session ID mismatch so operators know the chain crosses a restart.
107+
if cfg.SessionID != "" {
108+
if entries, readErr := store.ReadAll(); readErr == nil && len(entries) > 0 {
109+
last := entries[len(entries)-1]
110+
if last.SessionID != "" && last.SessionID != cfg.SessionID {
111+
c.warnings = append(c.warnings, fmt.Sprintf(
112+
"session ID mismatch: last chain entry has session_id=%q, current session is %q — chain spans a process restart",
113+
last.SessionID, cfg.SessionID,
114+
))
115+
}
116+
}
117+
}
118+
97119
return c, nil
98120
}
99121

@@ -174,14 +196,84 @@ type AuditEvent struct {
174196
Metadata map[string]string
175197
}
176198

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

0 commit comments

Comments
 (0)