Skip to content

Commit c3542f9

Browse files
OpsKernclaude
andcommitted
feat(compliance): PR 3 — StoreFullInputs sidecar payloads file
Add opt-in Config.StoreFullInputs that writes raw input/output JSON to a sidecar JSONL file at AuditPath+".payloads". The main chain and AuditStore interface are unchanged. - payloadWriter appends payloadEntry records (audit_id, timestamp, event_type, input, output) to the sidecar file, mode 0600 enforced - Messages.New writes full params+response after each allowed LLM call - Messages.NewStreaming writes params at stream start - Audit() writes event.Metadata as input payload when non-empty - PayloadsPath() exposes the sidecar path for operators - StoreFullInputs=false (default) creates no sidecar file and adds zero overhead to the hot path Enables EU AI Act Article 12 auditors to replay exactly what the agent sent and received without changing the tamper-evident chain format. gosec: 0 issues (3 nosec); go test -race: PASS; govulncheck: clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 314efa0 commit c3542f9

4 files changed

Lines changed: 223 additions & 6 deletions

File tree

messages.go

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

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"time"
78

@@ -47,6 +48,21 @@ func (s *MessagesService) New(ctx context.Context, params anthropic.MessageNewPa
4748
_ = s.wc.chain.Append(postEntry)
4849
}
4950

51+
if s.wc.payloads != nil {
52+
inputJSON, _ := json.Marshal(params)
53+
var outputJSON json.RawMessage
54+
if resp != nil {
55+
outputJSON, _ = json.Marshal(resp)
56+
}
57+
s.wc.payloads.write(payloadEntry{
58+
AuditID: entry.ID,
59+
Timestamp: entry.Timestamp.(time.Time),
60+
EventType: entry.EventType,
61+
Input: inputJSON,
62+
Output: outputJSON,
63+
})
64+
}
65+
5066
return resp, callErr
5167
}
5268

@@ -78,6 +94,16 @@ func (s *MessagesService) NewStreaming(ctx context.Context, params anthropic.Mes
7894
}
7995
}
8096

97+
if s.wc.payloads != nil {
98+
inputJSON, _ := json.Marshal(params)
99+
s.wc.payloads.write(payloadEntry{
100+
AuditID: entry.ID,
101+
Timestamp: entry.Timestamp.(time.Time),
102+
EventType: entry.EventType,
103+
Input: inputJSON,
104+
})
105+
}
106+
81107
inner := s.wc.inner.Messages.NewStreaming(ctx, params)
82108
return &WritStream{
83109
inner: inner,

payload.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package writ
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"sync"
9+
"time"
10+
)
11+
12+
// payloadEntry is a single record in the sidecar .payloads JSONL file.
13+
// It stores the raw JSON of the input and/or output for one audited operation.
14+
type payloadEntry struct {
15+
AuditID string `json:"audit_id"`
16+
Timestamp time.Time `json:"timestamp"`
17+
EventType string `json:"event_type"`
18+
Input json.RawMessage `json:"input,omitempty"`
19+
Output json.RawMessage `json:"output,omitempty"`
20+
}
21+
22+
// payloadWriter appends payloadEntry records to a sidecar JSONL file.
23+
// Write failures are silent: the main chain is authoritative; the payloads
24+
// file is supplementary and must not block normal operation.
25+
type payloadWriter struct {
26+
mu sync.Mutex
27+
path string
28+
}
29+
30+
func newPayloadWriter(auditPath string) (*payloadWriter, error) {
31+
path := filepath.Clean(auditPath) + ".payloads"
32+
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) //#nosec G304 -- construction-time path, same trust as AuditPath
33+
if err != nil {
34+
return nil, fmt.Errorf("writ: open payloads file: %w", err)
35+
}
36+
if err := f.Close(); err != nil {
37+
return nil, fmt.Errorf("writ: close payloads file: %w", err)
38+
}
39+
fi, err := os.Stat(path)
40+
if err != nil {
41+
return nil, fmt.Errorf("writ: stat payloads file: %w", err)
42+
}
43+
if fi.Mode().Perm() != 0o600 {
44+
if err := os.Chmod(path, 0o600); err != nil {
45+
return nil, fmt.Errorf("writ: payloads file has insecure permissions (%#o) and chmod failed: %w", fi.Mode().Perm(), err)
46+
}
47+
}
48+
return &payloadWriter{path: path}, nil
49+
}
50+
51+
func (pw *payloadWriter) write(entry payloadEntry) {
52+
pw.mu.Lock()
53+
defer pw.mu.Unlock()
54+
f, err := os.OpenFile(pw.path, os.O_APPEND|os.O_WRONLY, 0o600) //#nosec G304
55+
if err != nil {
56+
return
57+
}
58+
defer func() { _ = f.Close() }()
59+
line, err := json.Marshal(entry)
60+
if err != nil {
61+
return
62+
}
63+
_, _ = fmt.Fprintf(f, "%s\n", line)
64+
}

writ.go

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

88
import (
99
"context"
10+
"encoding/json"
1011
"errors"
1112
"fmt"
1213
"time"
@@ -60,6 +61,13 @@ type Config struct {
6061
// SessionID, a warning is added to Client.Warnings() to flag the
6162
// cross-session boundary for human review.
6263
SessionID string
64+
65+
// StoreFullInputs enables writing raw input/output JSON to a sidecar
66+
// file at AuditPath+".payloads". Opt-in; requires AuditPath to be set.
67+
// When false (default), only input/output hashes are recorded in the
68+
// main chain. Enable this when Article 12 auditors need to replay
69+
// exactly what the agent sent and received.
70+
StoreFullInputs bool
6371
}
6472

6573
// ErrCorruptChain is returned by New() when the existing chain fails Merkle
@@ -74,7 +82,8 @@ type Client struct {
7482
cfg Config
7583
gater *gateWrapper
7684
chain AuditStore
77-
warnings []string // non-fatal init warnings (e.g. session ID mismatch)
85+
warnings []string // non-fatal init warnings (e.g. session ID mismatch)
86+
payloads *payloadWriter // nil when StoreFullInputs is false
7887
}
7988

8089
// New constructs a writ.Client with lazy OPA policy reload.
@@ -126,12 +135,22 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) {
126135
return nil, fmt.Errorf("writ: init gate: %w", err)
127136
}
128137

138+
var pw *payloadWriter
139+
if cfg.StoreFullInputs && cfg.AuditPath != "" {
140+
var pwErr error
141+
pw, pwErr = newPayloadWriter(cfg.AuditPath)
142+
if pwErr != nil {
143+
return nil, fmt.Errorf("writ: init payload writer: %w", pwErr)
144+
}
145+
}
146+
129147
inner := anthropic.NewClient()
130148
c := &Client{
131-
inner: &inner,
132-
cfg: cfg,
133-
gater: g,
134-
chain: store,
149+
inner: &inner,
150+
cfg: cfg,
151+
gater: g,
152+
chain: store,
153+
payloads: pw,
135154
}
136155
c.Messages = &MessagesService{wc: c}
137156

@@ -151,6 +170,15 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) {
151170
return c, nil
152171
}
153172

173+
// PayloadsPath returns the path of the sidecar payloads file, or empty string
174+
// if StoreFullInputs is disabled or AuditPath was not set.
175+
func (c *Client) PayloadsPath() string {
176+
if c.payloads == nil {
177+
return ""
178+
}
179+
return c.payloads.path
180+
}
181+
154182
// DenialError is returned by Messages.New and Messages.NewStreaming when the
155183
// OPA gate denies the call. The LLM API is never contacted on denial.
156184
type DenialError struct {
@@ -301,6 +329,8 @@ func firstBrokenEntry(entries []ChainEntry) *ChainEntry {
301329
// Audit writes an explicit event to the writ chain. Use for tool use events
302330
// (file read, shell exec, web fetch) that require Article 12 granularity.
303331
// The chain entry includes a Merkle link to the previous entry.
332+
// When StoreFullInputs is enabled and event.Metadata is non-empty, the
333+
// metadata is also written to the sidecar payloads file.
304334
func (c *Client) Audit(event AuditEvent) error {
305335
if event.Timestamp.IsZero() {
306336
event.Timestamp = time.Now().UTC()
@@ -309,7 +339,20 @@ func (c *Client) Audit(event AuditEvent) error {
309339
if err != nil {
310340
return fmt.Errorf("writ.Audit: build entry: %w", err)
311341
}
312-
return c.chain.Append(entry)
342+
if appendErr := c.chain.Append(entry); appendErr != nil {
343+
return appendErr
344+
}
345+
if c.payloads != nil && len(event.Metadata) > 0 {
346+
if metaJSON, jsonErr := json.Marshal(event.Metadata); jsonErr == nil {
347+
c.payloads.write(payloadEntry{
348+
AuditID: entry.ID,
349+
Timestamp: event.Timestamp,
350+
EventType: entry.EventType,
351+
Input: metaJSON,
352+
})
353+
}
354+
}
355+
return nil
313356
}
314357

315358
// ChainProtected attempts to set the FS_APPEND_FL attribute (equivalent to

writ_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package writ_test
22

33
import (
4+
"bufio"
5+
"encoding/json"
46
"os"
57
"path/filepath"
68
"testing"
@@ -241,3 +243,85 @@ func TestVerifyFullNoGapsForSingleSession(t *testing.T) {
241243
t.Errorf("want no gaps for single session, got: %v", result.SessionGaps)
242244
}
243245
}
246+
247+
// PR 3: StoreFullInputs tests.
248+
249+
func TestStoreFullInputsCreatesPayloadsFile(t *testing.T) {
250+
cfg := testConfig(t)
251+
cfg.StoreFullInputs = true
252+
c, err := writ.New(cfg)
253+
if err != nil {
254+
t.Fatalf("New: %v", err)
255+
}
256+
wantPath := cfg.AuditPath + ".payloads"
257+
if c.PayloadsPath() != wantPath {
258+
t.Fatalf("want PayloadsPath=%q, got %q", wantPath, c.PayloadsPath())
259+
}
260+
if _, err := os.Stat(wantPath); err != nil {
261+
t.Fatalf("payloads file not created: %v", err)
262+
}
263+
fi, err := os.Stat(wantPath)
264+
if err != nil {
265+
t.Fatalf("stat payloads file: %v", err)
266+
}
267+
if fi.Mode().Perm() != 0o600 {
268+
t.Errorf("want payloads file mode 0600, got %#o", fi.Mode().Perm())
269+
}
270+
}
271+
272+
func TestStoreFullInputsDisabledNoFile(t *testing.T) {
273+
cfg := testConfig(t)
274+
// StoreFullInputs defaults to false.
275+
c, err := writ.New(cfg)
276+
if err != nil {
277+
t.Fatalf("New: %v", err)
278+
}
279+
if c.PayloadsPath() != "" {
280+
t.Errorf("want empty PayloadsPath when StoreFullInputs=false, got %q", c.PayloadsPath())
281+
}
282+
if _, err := os.Stat(cfg.AuditPath + ".payloads"); !os.IsNotExist(err) {
283+
t.Error("want no payloads file when StoreFullInputs=false")
284+
}
285+
}
286+
287+
func TestStoreFullInputsAuditWritesPayload(t *testing.T) {
288+
cfg := testConfig(t)
289+
cfg.StoreFullInputs = true
290+
c, err := writ.New(cfg)
291+
if err != nil {
292+
t.Fatalf("New: %v", err)
293+
}
294+
295+
if err := c.Audit(writ.AuditEvent{
296+
EventType: "tool_use",
297+
ActionType: "read_file",
298+
Metadata: map[string]string{"path": "/etc/passwd", "reason": "audit test"},
299+
}); err != nil {
300+
t.Fatalf("Audit: %v", err)
301+
}
302+
303+
f, err := os.Open(c.PayloadsPath())
304+
if err != nil {
305+
t.Fatalf("open payloads file: %v", err)
306+
}
307+
defer f.Close()
308+
309+
var entries []map[string]json.RawMessage
310+
scanner := bufio.NewScanner(f)
311+
for scanner.Scan() {
312+
var e map[string]json.RawMessage
313+
if err := json.Unmarshal(scanner.Bytes(), &e); err != nil {
314+
t.Fatalf("unmarshal payload entry: %v", err)
315+
}
316+
entries = append(entries, e)
317+
}
318+
if len(entries) != 1 {
319+
t.Fatalf("want 1 payload entry, got %d", len(entries))
320+
}
321+
if _, ok := entries[0]["audit_id"]; !ok {
322+
t.Error("payload entry missing audit_id")
323+
}
324+
if _, ok := entries[0]["input"]; !ok {
325+
t.Error("payload entry missing input")
326+
}
327+
}

0 commit comments

Comments
 (0)