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") + } +}