Skip to content

Commit 97facb9

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 b125cb0 commit 97facb9

4 files changed

Lines changed: 243 additions & 5 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

@@ -43,6 +44,21 @@ func (s *MessagesService) New(ctx context.Context, params anthropic.MessageNewPa
4344
_ = s.wc.chain.Append(postEntry)
4445
}
4546

47+
if s.wc.payloads != nil {
48+
inputJSON, _ := json.Marshal(params)
49+
var outputJSON json.RawMessage
50+
if resp != nil {
51+
outputJSON, _ = json.Marshal(resp)
52+
}
53+
s.wc.payloads.write(payloadEntry{
54+
AuditID: entry.ID,
55+
Timestamp: entry.Timestamp.(time.Time),
56+
EventType: entry.EventType,
57+
Input: inputJSON,
58+
Output: outputJSON,
59+
})
60+
}
61+
4662
return resp, callErr
4763
}
4864

@@ -70,6 +86,16 @@ func (s *MessagesService) NewStreaming(ctx context.Context, params anthropic.Mes
7086
}
7187
}
7288

89+
if s.wc.payloads != nil {
90+
inputJSON, _ := json.Marshal(params)
91+
s.wc.payloads.write(payloadEntry{
92+
AuditID: entry.ID,
93+
Timestamp: entry.Timestamp.(time.Time),
94+
EventType: entry.EventType,
95+
Input: inputJSON,
96+
})
97+
}
98+
7399
inner := s.wc.inner.Messages.NewStreaming(ctx, params)
74100
return &WritStream{
75101
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: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package writ
77

88
import (
99
"context"
10+
"encoding/json"
1011
"fmt"
1112
"time"
1213

@@ -42,6 +43,13 @@ type Config struct {
4243
// EagerReload enables a goroutine-based policy watcher.
4344
// Default (false) uses lazy reload: policy is re-read when mtime changes.
4445
EagerReload bool
46+
47+
// StoreFullInputs enables writing raw input/output JSON to a sidecar
48+
// file at AuditPath+".payloads". Opt-in; requires AuditPath to be set.
49+
// When false (default), only input/output hashes are recorded in the
50+
// main chain. Enable this when Article 12 auditors need to replay
51+
// exactly what the agent sent and received.
52+
StoreFullInputs bool
4553
}
4654

4755
// Client wraps an anthropic.Client with a pre-call codification gate and
@@ -52,6 +60,7 @@ type Client struct {
5260
cfg Config
5361
gater *gateWrapper
5462
chain AuditStore
63+
payloads *payloadWriter // nil when StoreFullInputs is false
5564
}
5665

5766
// New constructs a writ.Client with lazy OPA policy reload.
@@ -86,17 +95,36 @@ func NewWithContext(ctx context.Context, cfg Config) (*Client, error) {
8695
return nil, fmt.Errorf("writ: init gate: %w", err)
8796
}
8897

98+
var pw *payloadWriter
99+
if cfg.StoreFullInputs && cfg.AuditPath != "" {
100+
var pwErr error
101+
pw, pwErr = newPayloadWriter(cfg.AuditPath)
102+
if pwErr != nil {
103+
return nil, fmt.Errorf("writ: init payload writer: %w", pwErr)
104+
}
105+
}
106+
89107
inner := anthropic.NewClient()
90108
c := &Client{
91-
inner: &inner,
92-
cfg: cfg,
93-
gater: g,
94-
chain: store,
109+
inner: &inner,
110+
cfg: cfg,
111+
gater: g,
112+
chain: store,
113+
payloads: pw,
95114
}
96115
c.Messages = &MessagesService{wc: c}
97116
return c, nil
98117
}
99118

119+
// PayloadsPath returns the path of the sidecar payloads file, or empty string
120+
// if StoreFullInputs is disabled or AuditPath was not set.
121+
func (c *Client) PayloadsPath() string {
122+
if c.payloads == nil {
123+
return ""
124+
}
125+
return c.payloads.path
126+
}
127+
100128
// DenialError is returned by Messages.New and Messages.NewStreaming when the
101129
// OPA gate denies the call. The LLM API is never contacted on denial.
102130
type DenialError struct {
@@ -177,6 +205,8 @@ type AuditEvent struct {
177205
// Audit writes an explicit event to the writ chain. Use for tool use events
178206
// (file read, shell exec, web fetch) that require Article 12 granularity.
179207
// The chain entry includes a Merkle link to the previous entry.
208+
// When StoreFullInputs is enabled and event.Metadata is non-empty, the
209+
// metadata is also written to the sidecar payloads file.
180210
func (c *Client) Audit(event AuditEvent) error {
181211
if event.Timestamp.IsZero() {
182212
event.Timestamp = time.Now().UTC()
@@ -185,7 +215,20 @@ func (c *Client) Audit(event AuditEvent) error {
185215
if err != nil {
186216
return fmt.Errorf("writ.Audit: build entry: %w", err)
187217
}
188-
return c.chain.Append(entry)
218+
if appendErr := c.chain.Append(entry); appendErr != nil {
219+
return appendErr
220+
}
221+
if c.payloads != nil && len(event.Metadata) > 0 {
222+
if metaJSON, jsonErr := json.Marshal(event.Metadata); jsonErr == nil {
223+
c.payloads.write(payloadEntry{
224+
AuditID: entry.ID,
225+
Timestamp: event.Timestamp,
226+
EventType: entry.EventType,
227+
Input: metaJSON,
228+
})
229+
}
230+
}
231+
return nil
189232
}
190233

191234
// Verify reads the chain at chainPath and verifies the Merkle hash links.

writ_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
package writ_test
22

33
import (
4+
"bufio"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
48
"testing"
59
"time"
610

711
"github.com/opskernel-io/writ"
812
)
913

14+
// testConfig creates a minimal writ.Config backed by a temp directory.
15+
func testConfig(t *testing.T) writ.Config {
16+
t.Helper()
17+
dir := t.TempDir()
18+
policyPath := filepath.Join(dir, "policy")
19+
if err := os.MkdirAll(policyPath, 0o700); err != nil {
20+
t.Fatalf("mkdir policy: %v", err)
21+
}
22+
const policy = "package writ.gate\nimport rego.v1\ndefault allow := true\ndefault tier := 2\ndefault denial_reason := \"\""
23+
if err := os.WriteFile(filepath.Join(policyPath, "writ.rego"), []byte(policy), 0o600); err != nil {
24+
t.Fatalf("write test policy: %v", err)
25+
}
26+
return writ.Config{
27+
PolicyPath: policyPath,
28+
AuditPath: filepath.Join(dir, "audit.chain"),
29+
CallerID: "test-agent",
30+
}
31+
}
32+
1033
func TestMemoryStoreAppendAndVerify(t *testing.T) {
1134
store := writ.NewMemoryStore()
1235

@@ -58,3 +81,85 @@ func TestDenialErrorMessage(t *testing.T) {
5881
t.Fatal("DenialError.Error() returned empty string")
5982
}
6083
}
84+
85+
// PR 3: StoreFullInputs tests.
86+
87+
func TestStoreFullInputsCreatesPayloadsFile(t *testing.T) {
88+
cfg := testConfig(t)
89+
cfg.StoreFullInputs = true
90+
c, err := writ.New(cfg)
91+
if err != nil {
92+
t.Fatalf("New: %v", err)
93+
}
94+
wantPath := cfg.AuditPath + ".payloads"
95+
if c.PayloadsPath() != wantPath {
96+
t.Fatalf("want PayloadsPath=%q, got %q", wantPath, c.PayloadsPath())
97+
}
98+
if _, err := os.Stat(wantPath); err != nil {
99+
t.Fatalf("payloads file not created: %v", err)
100+
}
101+
fi, err := os.Stat(wantPath)
102+
if err != nil {
103+
t.Fatalf("stat payloads file: %v", err)
104+
}
105+
if fi.Mode().Perm() != 0o600 {
106+
t.Errorf("want payloads file mode 0600, got %#o", fi.Mode().Perm())
107+
}
108+
}
109+
110+
func TestStoreFullInputsDisabledNoFile(t *testing.T) {
111+
cfg := testConfig(t)
112+
// StoreFullInputs defaults to false.
113+
c, err := writ.New(cfg)
114+
if err != nil {
115+
t.Fatalf("New: %v", err)
116+
}
117+
if c.PayloadsPath() != "" {
118+
t.Errorf("want empty PayloadsPath when StoreFullInputs=false, got %q", c.PayloadsPath())
119+
}
120+
if _, err := os.Stat(cfg.AuditPath + ".payloads"); !os.IsNotExist(err) {
121+
t.Error("want no payloads file when StoreFullInputs=false")
122+
}
123+
}
124+
125+
func TestStoreFullInputsAuditWritesPayload(t *testing.T) {
126+
cfg := testConfig(t)
127+
cfg.StoreFullInputs = true
128+
c, err := writ.New(cfg)
129+
if err != nil {
130+
t.Fatalf("New: %v", err)
131+
}
132+
133+
if err := c.Audit(writ.AuditEvent{
134+
EventType: "tool_use",
135+
ActionType: "read_file",
136+
Metadata: map[string]string{"path": "/etc/passwd", "reason": "audit test"},
137+
}); err != nil {
138+
t.Fatalf("Audit: %v", err)
139+
}
140+
141+
f, err := os.Open(c.PayloadsPath())
142+
if err != nil {
143+
t.Fatalf("open payloads file: %v", err)
144+
}
145+
defer f.Close()
146+
147+
var entries []map[string]json.RawMessage
148+
scanner := bufio.NewScanner(f)
149+
for scanner.Scan() {
150+
var e map[string]json.RawMessage
151+
if err := json.Unmarshal(scanner.Bytes(), &e); err != nil {
152+
t.Fatalf("unmarshal payload entry: %v", err)
153+
}
154+
entries = append(entries, e)
155+
}
156+
if len(entries) != 1 {
157+
t.Fatalf("want 1 payload entry, got %d", len(entries))
158+
}
159+
if _, ok := entries[0]["audit_id"]; !ok {
160+
t.Error("payload entry missing audit_id")
161+
}
162+
if _, ok := entries[0]["input"]; !ok {
163+
t.Error("payload entry missing input")
164+
}
165+
}

0 commit comments

Comments
 (0)