From c3542f9f38d459756f311ba574da153336fc628d Mon Sep 17 00:00:00 2001 From: Will Smith <18408743+OpsKern@users.noreply.github.com> Date: Mon, 4 May 2026 22:33:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(compliance):=20PR=203=20=E2=80=94=20StoreF?= =?UTF-8?q?ullInputs=20sidecar=20payloads=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages.go | 26 ++++++++++++++++ payload.go | 64 +++++++++++++++++++++++++++++++++++++++ writ.go | 55 ++++++++++++++++++++++++++++++---- writ_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 payload.go diff --git a/messages.go b/messages.go index 3622303..248f1b0 100644 --- a/messages.go +++ b/messages.go @@ -2,6 +2,7 @@ package writ import ( "context" + "encoding/json" "fmt" "time" @@ -47,6 +48,21 @@ func (s *MessagesService) New(ctx context.Context, params anthropic.MessageNewPa _ = s.wc.chain.Append(postEntry) } + if s.wc.payloads != nil { + inputJSON, _ := json.Marshal(params) + var outputJSON json.RawMessage + if resp != nil { + outputJSON, _ = json.Marshal(resp) + } + s.wc.payloads.write(payloadEntry{ + AuditID: entry.ID, + Timestamp: entry.Timestamp.(time.Time), + EventType: entry.EventType, + Input: inputJSON, + Output: outputJSON, + }) + } + return resp, callErr } @@ -78,6 +94,16 @@ func (s *MessagesService) NewStreaming(ctx context.Context, params anthropic.Mes } } + if s.wc.payloads != nil { + inputJSON, _ := json.Marshal(params) + s.wc.payloads.write(payloadEntry{ + AuditID: entry.ID, + Timestamp: entry.Timestamp.(time.Time), + EventType: entry.EventType, + Input: inputJSON, + }) + } + inner := s.wc.inner.Messages.NewStreaming(ctx, params) return &WritStream{ inner: inner, diff --git a/payload.go b/payload.go new file mode 100644 index 0000000..0a38caf --- /dev/null +++ b/payload.go @@ -0,0 +1,64 @@ +package writ + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// payloadEntry is a single record in the sidecar .payloads JSONL file. +// It stores the raw JSON of the input and/or output for one audited operation. +type payloadEntry struct { + AuditID string `json:"audit_id"` + Timestamp time.Time `json:"timestamp"` + EventType string `json:"event_type"` + Input json.RawMessage `json:"input,omitempty"` + Output json.RawMessage `json:"output,omitempty"` +} + +// payloadWriter appends payloadEntry records to a sidecar JSONL file. +// Write failures are silent: the main chain is authoritative; the payloads +// file is supplementary and must not block normal operation. +type payloadWriter struct { + mu sync.Mutex + path string +} + +func newPayloadWriter(auditPath string) (*payloadWriter, error) { + path := filepath.Clean(auditPath) + ".payloads" + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) //#nosec G304 -- construction-time path, same trust as AuditPath + if err != nil { + return nil, fmt.Errorf("writ: open payloads file: %w", err) + } + if err := f.Close(); err != nil { + return nil, fmt.Errorf("writ: close payloads file: %w", err) + } + fi, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("writ: stat payloads file: %w", err) + } + if fi.Mode().Perm() != 0o600 { + if err := os.Chmod(path, 0o600); err != nil { + return nil, fmt.Errorf("writ: payloads file has insecure permissions (%#o) and chmod failed: %w", fi.Mode().Perm(), err) + } + } + return &payloadWriter{path: path}, nil +} + +func (pw *payloadWriter) write(entry payloadEntry) { + pw.mu.Lock() + defer pw.mu.Unlock() + f, err := os.OpenFile(pw.path, os.O_APPEND|os.O_WRONLY, 0o600) //#nosec G304 + if err != nil { + return + } + defer func() { _ = f.Close() }() + line, err := json.Marshal(entry) + if err != nil { + return + } + _, _ = fmt.Fprintf(f, "%s\n", line) +} diff --git a/writ.go b/writ.go index e1e1bd0..791b02c 100644 --- a/writ.go +++ b/writ.go @@ -7,6 +7,7 @@ package writ import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -60,6 +61,13 @@ type Config struct { // SessionID, a warning is added to Client.Warnings() to flag the // cross-session boundary for human review. SessionID string + + // StoreFullInputs enables writing raw input/output JSON to a sidecar + // file at AuditPath+".payloads". Opt-in; requires AuditPath to be set. + // When false (default), only input/output hashes are recorded in the + // main chain. Enable this when Article 12 auditors need to replay + // exactly what the agent sent and received. + StoreFullInputs bool } // ErrCorruptChain is returned by New() when the existing chain fails Merkle @@ -74,7 +82,8 @@ type Client struct { cfg Config gater *gateWrapper chain AuditStore - warnings []string // non-fatal init warnings (e.g. session ID mismatch) + warnings []string // non-fatal init warnings (e.g. session ID mismatch) + payloads *payloadWriter // nil when StoreFullInputs is false } // New constructs a writ.Client with lazy OPA policy reload. @@ -126,12 +135,22 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) { return nil, fmt.Errorf("writ: init gate: %w", err) } + var pw *payloadWriter + if cfg.StoreFullInputs && cfg.AuditPath != "" { + var pwErr error + pw, pwErr = newPayloadWriter(cfg.AuditPath) + if pwErr != nil { + return nil, fmt.Errorf("writ: init payload writer: %w", pwErr) + } + } + inner := anthropic.NewClient() c := &Client{ - inner: &inner, - cfg: cfg, - gater: g, - chain: store, + inner: &inner, + cfg: cfg, + gater: g, + chain: store, + payloads: pw, } c.Messages = &MessagesService{wc: c} @@ -151,6 +170,15 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) { return c, nil } +// PayloadsPath returns the path of the sidecar payloads file, or empty string +// if StoreFullInputs is disabled or AuditPath was not set. +func (c *Client) PayloadsPath() string { + if c.payloads == nil { + return "" + } + return c.payloads.path +} + // DenialError is returned by Messages.New and Messages.NewStreaming when the // OPA gate denies the call. The LLM API is never contacted on denial. type DenialError struct { @@ -301,6 +329,8 @@ func firstBrokenEntry(entries []ChainEntry) *ChainEntry { // Audit writes an explicit event to the writ chain. Use for tool use events // (file read, shell exec, web fetch) that require Article 12 granularity. // The chain entry includes a Merkle link to the previous entry. +// When StoreFullInputs is enabled and event.Metadata is non-empty, the +// metadata is also written to the sidecar payloads file. func (c *Client) Audit(event AuditEvent) error { if event.Timestamp.IsZero() { event.Timestamp = time.Now().UTC() @@ -309,7 +339,20 @@ func (c *Client) Audit(event AuditEvent) error { if err != nil { return fmt.Errorf("writ.Audit: build entry: %w", err) } - return c.chain.Append(entry) + if appendErr := c.chain.Append(entry); appendErr != nil { + return appendErr + } + if c.payloads != nil && len(event.Metadata) > 0 { + if metaJSON, jsonErr := json.Marshal(event.Metadata); jsonErr == nil { + c.payloads.write(payloadEntry{ + AuditID: entry.ID, + Timestamp: event.Timestamp, + EventType: entry.EventType, + Input: metaJSON, + }) + } + } + return nil } // ChainProtected attempts to set the FS_APPEND_FL attribute (equivalent to diff --git a/writ_test.go b/writ_test.go index 8cb8829..df384b2 100644 --- a/writ_test.go +++ b/writ_test.go @@ -1,6 +1,8 @@ package writ_test import ( + "bufio" + "encoding/json" "os" "path/filepath" "testing" @@ -241,3 +243,85 @@ func TestVerifyFullNoGapsForSingleSession(t *testing.T) { t.Errorf("want no gaps for single session, got: %v", result.SessionGaps) } } + +// PR 3: StoreFullInputs tests. + +func TestStoreFullInputsCreatesPayloadsFile(t *testing.T) { + cfg := testConfig(t) + cfg.StoreFullInputs = true + c, err := writ.New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + wantPath := cfg.AuditPath + ".payloads" + if c.PayloadsPath() != wantPath { + t.Fatalf("want PayloadsPath=%q, got %q", wantPath, c.PayloadsPath()) + } + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("payloads file not created: %v", err) + } + fi, err := os.Stat(wantPath) + if err != nil { + t.Fatalf("stat payloads file: %v", err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("want payloads file mode 0600, got %#o", fi.Mode().Perm()) + } +} + +func TestStoreFullInputsDisabledNoFile(t *testing.T) { + cfg := testConfig(t) + // StoreFullInputs defaults to false. + c, err := writ.New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + if c.PayloadsPath() != "" { + t.Errorf("want empty PayloadsPath when StoreFullInputs=false, got %q", c.PayloadsPath()) + } + if _, err := os.Stat(cfg.AuditPath + ".payloads"); !os.IsNotExist(err) { + t.Error("want no payloads file when StoreFullInputs=false") + } +} + +func TestStoreFullInputsAuditWritesPayload(t *testing.T) { + cfg := testConfig(t) + cfg.StoreFullInputs = true + c, err := writ.New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + + if err := c.Audit(writ.AuditEvent{ + EventType: "tool_use", + ActionType: "read_file", + Metadata: map[string]string{"path": "/etc/passwd", "reason": "audit test"}, + }); err != nil { + t.Fatalf("Audit: %v", err) + } + + f, err := os.Open(c.PayloadsPath()) + if err != nil { + t.Fatalf("open payloads file: %v", err) + } + defer f.Close() + + var entries []map[string]json.RawMessage + scanner := bufio.NewScanner(f) + for scanner.Scan() { + var e map[string]json.RawMessage + if err := json.Unmarshal(scanner.Bytes(), &e); err != nil { + t.Fatalf("unmarshal payload entry: %v", err) + } + entries = append(entries, e) + } + if len(entries) != 1 { + t.Fatalf("want 1 payload entry, got %d", len(entries)) + } + if _, ok := entries[0]["audit_id"]; !ok { + t.Error("payload entry missing audit_id") + } + if _, ok := entries[0]["input"]; !ok { + t.Error("payload entry missing input") + } +}