Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package writ

import (
"context"
"encoding/json"
"fmt"
"time"

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down
64 changes: 64 additions & 0 deletions payload.go
Original file line number Diff line number Diff line change
@@ -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)
}
55 changes: 49 additions & 6 deletions writ.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package writ

import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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}

Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
84 changes: 84 additions & 0 deletions writ_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package writ_test

import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -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")
}
}
Loading