From 77680c74458d6f27a32698db5c409a601b23d086 Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 22 Jun 2026 23:42:18 +0200 Subject: [PATCH] feat(audit): minimal slog AuditSink for capture I5 (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emits the request-level audit record to structured logs (scraped by the existing alloy/loki substrate) and surfaces security events at warn level. Placeholder behind the AuditSink interface — the classification- aware loki+buffer+reconcile sink (confidential fails closed, internal degrades) lands in #54 and replaces this without touching callers. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/internal/audit/slog.go | 48 +++++++++++++++++++++++++++ ingestion/internal/audit/slog_test.go | 41 +++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 ingestion/internal/audit/slog.go create mode 100644 ingestion/internal/audit/slog_test.go diff --git a/ingestion/internal/audit/slog.go b/ingestion/internal/audit/slog.go new file mode 100644 index 0000000..b7d57d2 --- /dev/null +++ b/ingestion/internal/audit/slog.go @@ -0,0 +1,48 @@ +// Package audit provides AuditSink implementations for the capture +// capability (I5). This file ships the minimal slog-backed sink used in +// #53: it emits the request-level audit record to structured logs, which +// the alloy/loki substrate already scrapes. The classification-aware +// degradation/refusal sink (confidential fails closed, internal buffers + +// reconciles) lands in #54 and replaces this behind the same interface. +package audit + +import ( + "context" + "log/slog" + + "github.com/mathiasbq/hyperguild/ingestion/internal/capture" +) + +// SlogSink records audit entries to an slog.Logger. It never fails, so it +// does not exercise the I5 floor (refuse-if-unauditable) — that is #54's +// loki+buffer sink. A nil logger falls back to slog.Default(). +type SlogSink struct { + logger *slog.Logger +} + +// NewSlogSink constructs a SlogSink. nil logger ⇒ slog.Default(). +func NewSlogSink(logger *slog.Logger) *SlogSink { + if logger == nil { + logger = slog.Default() + } + return &SlogSink{logger: logger} +} + +// Record emits the audit entry at info level. Security events, when +// present, are logged at warn level so they surface independently of the +// routine audit stream. +func (s *SlogSink) Record(_ context.Context, e capture.AuditEntry) error { + s.logger.Info("capture audit", + "principal", e.Principal, + "actor", e.Actor, + "harness", e.Harness, + "session_ref", e.SessionRef, + "classification", e.EffectiveClassification, + "items", e.Items, + "ts", e.Timestamp, + ) + for _, ev := range e.SecurityEvents { + s.logger.Warn("capture security event", "principal", e.Principal, "event", ev) + } + return nil +} diff --git a/ingestion/internal/audit/slog_test.go b/ingestion/internal/audit/slog_test.go new file mode 100644 index 0000000..687528d --- /dev/null +++ b/ingestion/internal/audit/slog_test.go @@ -0,0 +1,41 @@ +package audit_test + +import ( + "bytes" + "context" + "log/slog" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/audit" + "github.com/mathiasbq/hyperguild/ingestion/internal/capture" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSlogSinkRecordsEntryAndSecurityEvents(t *testing.T) { + var buf bytes.Buffer + sink := audit.NewSlogSink(slog.New(slog.NewTextHandler(&buf, nil))) + + err := sink.Record(context.Background(), capture.AuditEntry{ + Principal: "koala-cli", + Harness: "claude-code", + EffectiveClassification: "confidential", + Items: []string{"insight:wiki/a/facts/x.md"}, + SecurityEvents: []string{"asserted-vs-derived origin mismatch"}, + }) + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "capture audit") + assert.Contains(t, out, "koala-cli") + assert.Contains(t, out, "confidential") + assert.Contains(t, out, "capture security event") + assert.Contains(t, out, "asserted-vs-derived origin mismatch") +} + +func TestSlogSinkNilLoggerDefaults(t *testing.T) { + // nil logger must not panic. + require.NotPanics(t, func() { + _ = audit.NewSlogSink(nil).Record(context.Background(), capture.AuditEntry{}) + }) +}