Skip to content

Commit 314efa0

Browse files
OpsKernclaude
andcommitted
feat(compliance): PR 2 — restart continuity via SessionID
SessionID field on Config/ChainEntry/HashContent; Warnings() and VerifyFull() with SessionGaps for cross-restart boundary detection. Merged with PR-1 (AllowedCallers) and CTO C-5 (computeEntryHash, openChainVerify) — all three feature sets coexist cleanly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 54adbcd commit 314efa0

6 files changed

Lines changed: 231 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
@@ -53,6 +53,13 @@ type Config struct {
5353
// that writes to this chain. Nil or empty allows any CallerID.
5454
// writ.New returns an error if CallerID is not in the list when it is set.
5555
AllowedCallers []string
56+
57+
// SessionID is a stable identifier for the current process run
58+
// (e.g. a UUID generated at startup). Written into each chain entry.
59+
// On writ.New(), if the chain's last entry has a different non-empty
60+
// SessionID, a warning is added to Client.Warnings() to flag the
61+
// cross-session boundary for human review.
62+
SessionID string
5663
}
5764

5865
// ErrCorruptChain is returned by New() when the existing chain fails Merkle
@@ -67,6 +74,7 @@ type Client struct {
6774
cfg Config
6875
gater *gateWrapper
6976
chain AuditStore
77+
warnings []string // non-fatal init warnings (e.g. session ID mismatch)
7078
}
7179

7280
// New constructs a writ.Client with lazy OPA policy reload.
@@ -126,6 +134,20 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) {
126134
chain: store,
127135
}
128136
c.Messages = &MessagesService{wc: c}
137+
138+
// Warn on session ID mismatch so operators know the chain crosses a restart.
139+
if cfg.SessionID != "" {
140+
if entries, readErr := store.ReadAll(); readErr == nil && len(entries) > 0 {
141+
last := entries[len(entries)-1]
142+
if last.SessionID != "" && last.SessionID != cfg.SessionID {
143+
c.warnings = append(c.warnings, fmt.Sprintf(
144+
"session ID mismatch: last chain entry has session_id=%q, current session is %q — chain spans a process restart",
145+
last.SessionID, cfg.SessionID,
146+
))
147+
}
148+
}
149+
}
150+
129151
return c, nil
130152
}
131153

@@ -206,14 +228,84 @@ type AuditEvent struct {
206228
Metadata map[string]string
207229
}
208230

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

writ_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,123 @@ func TestChainProtectedReturnsBool(t *testing.T) {
121121
// Return value is platform/filesystem-dependent; just verify the call succeeds.
122122
_ = c.ChainProtected()
123123
}
124+
125+
// PR 2: SessionID, Warnings, VerifyFull tests.
126+
127+
func TestSessionIDWrittenToChain(t *testing.T) {
128+
cfg := testConfig(t)
129+
cfg.SessionID = "session-abc"
130+
c, err := writ.New(cfg)
131+
if err != nil {
132+
t.Fatalf("New: %v", err)
133+
}
134+
if err := c.Audit(writ.AuditEvent{EventType: "tool_use", ActionType: "noop"}); err != nil {
135+
t.Fatalf("Audit: %v", err)
136+
}
137+
result, err := c.VerifyFull()
138+
if err != nil {
139+
t.Fatalf("VerifyFull: %v", err)
140+
}
141+
if !result.Valid {
142+
t.Fatalf("want valid chain, got invalid")
143+
}
144+
if result.EntryCount != 1 {
145+
t.Fatalf("want 1 entry, got %d", result.EntryCount)
146+
}
147+
}
148+
149+
func TestSessionIDMismatchWarning(t *testing.T) {
150+
cfg := testConfig(t)
151+
cfg.SessionID = "session-A"
152+
c1, err := writ.New(cfg)
153+
if err != nil {
154+
t.Fatalf("New (session-A): %v", err)
155+
}
156+
if err := c1.Audit(writ.AuditEvent{EventType: "tool_use", ActionType: "noop"}); err != nil {
157+
t.Fatalf("Audit: %v", err)
158+
}
159+
160+
cfg.SessionID = "session-B"
161+
c2, err := writ.New(cfg)
162+
if err != nil {
163+
t.Fatalf("New (session-B): %v", err)
164+
}
165+
if len(c2.Warnings()) == 0 {
166+
t.Error("want session ID mismatch warning, got none")
167+
}
168+
}
169+
170+
func TestSessionIDMatchNoWarning(t *testing.T) {
171+
cfg := testConfig(t)
172+
cfg.SessionID = "session-A"
173+
c1, err := writ.New(cfg)
174+
if err != nil {
175+
t.Fatalf("New: %v", err)
176+
}
177+
if err := c1.Audit(writ.AuditEvent{EventType: "tool_use", ActionType: "noop"}); err != nil {
178+
t.Fatalf("Audit: %v", err)
179+
}
180+
c2, err := writ.New(cfg) // same SessionID
181+
if err != nil {
182+
t.Fatalf("New (same session): %v", err)
183+
}
184+
if len(c2.Warnings()) != 0 {
185+
t.Errorf("want no warnings for matching session ID, got: %v", c2.Warnings())
186+
}
187+
}
188+
189+
func TestVerifyFullSessionGapDetected(t *testing.T) {
190+
cfg := testConfig(t)
191+
cfg.SessionID = "session-A"
192+
c1, err := writ.New(cfg)
193+
if err != nil {
194+
t.Fatalf("New (A): %v", err)
195+
}
196+
if err := c1.Audit(writ.AuditEvent{EventType: "tool_use", ActionType: "noop"}); err != nil {
197+
t.Fatalf("Audit A: %v", err)
198+
}
199+
200+
cfg.SessionID = "session-B"
201+
c2, err := writ.New(cfg)
202+
if err != nil {
203+
t.Fatalf("New (B): %v", err)
204+
}
205+
if err := c2.Audit(writ.AuditEvent{EventType: "tool_use", ActionType: "noop"}); err != nil {
206+
t.Fatalf("Audit B: %v", err)
207+
}
208+
209+
result, err := c2.VerifyFull()
210+
if err != nil {
211+
t.Fatalf("VerifyFull: %v", err)
212+
}
213+
if !result.Valid {
214+
t.Fatal("want valid chain despite session gap")
215+
}
216+
if len(result.SessionGaps) == 0 {
217+
t.Error("want 1 session gap, got none")
218+
}
219+
}
220+
221+
func TestVerifyFullNoGapsForSingleSession(t *testing.T) {
222+
cfg := testConfig(t)
223+
cfg.SessionID = "session-X"
224+
c, err := writ.New(cfg)
225+
if err != nil {
226+
t.Fatalf("New: %v", err)
227+
}
228+
for i := 0; i < 3; i++ {
229+
if err := c.Audit(writ.AuditEvent{EventType: "tool_use", ActionType: "noop"}); err != nil {
230+
t.Fatalf("Audit %d: %v", i, err)
231+
}
232+
}
233+
result, err := c.VerifyFull()
234+
if err != nil {
235+
t.Fatalf("VerifyFull: %v", err)
236+
}
237+
if !result.Valid {
238+
t.Fatal("want valid chain")
239+
}
240+
if len(result.SessionGaps) != 0 {
241+
t.Errorf("want no gaps for single session, got: %v", result.SessionGaps)
242+
}
243+
}

0 commit comments

Comments
 (0)