Help us shape the future of Go! Take the 2026 Go Developer Survey

audit

package
v1.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

Documentation

Overview

Package audit — BufferedAsyncLogger.

BufferedAsyncLogger wraps any audit.Logger with a bounded channel and a pool of N worker goroutines that drain the channel into the underlying Log call. The wrapper:

  • Keeps the /v1/check hot path off the audit mutex: workers absorb I/O latency; Log() returns after a channel send or overflow append.
  • On saturation, persists entries to a JSON-Lines overflow file (mode 0600); a recovery goroutine drains the overflow back into the channel when capacity returns. Entries are durable across the saturation boundary; they are NOT ordered across that boundary.
  • Runs JSON encode + file write on workers, so the FileLogger.mu hold shrinks to the actual encode+write.

Concurrency contract:

  • Log MUST NOT block. Path A: non-blocking channel send. Path B: append a JSON line to the overflow file. Either path returns nil to the caller. The only error Log can return is "queue full AND overflow append failed" — both legs of durability gone.
  • Close is idempotent (sync.Once). It closes the recovery goroutine's stop channel, closes the work queue (workers drain remaining entries), and then runs a one-shot best-effort overflow drain bounded by a short timeout.
  • The recovery goroutine runs at RecoveryInterval (default 5s). On each tick it atomically renames the overflow file to a `.draining.<ts>` sibling so concurrent Log() callers do not see partially drained state, then reads each line, attempts a non-blocking enqueue, and re-spills any line that would have blocked into a fresh overflow file.
  • Counters are exposed via getters that return atomic loads. Callers see a value at *some* point in time, not a snapshot across all three.

Index

Constants

View Source
const (
	// TransportClaudeCode: `agentguard hook claude-code`.
	TransportClaudeCode = "claude_code"
	// TransportCursor: `agentguard hook cursor`.
	TransportCursor = "cursor"
	// TransportGeminiCLI: `agentguard hook gemini-cli`.
	TransportGeminiCLI = "gemini_cli"
	// TransportCodexCLI: `agentguard hook codex`.
	TransportCodexCLI = "codex_cli"
)

The coding-agent hooks (`agentguard hook <agent>`) stamp one transport per agent, so the audit log and dashboard tell them apart.

View Source
const (
	SealAlgHMACSHA256 = "hmac-sha256"
	SealAlgSHA256     = "sha256"
)

Seal algorithms, as written in MetaRecord.Seal.Alg.

View Source
const (
	// DefaultMaxTrackedSessions is the fewest sessions a scan of the file
	// backend keeps in memory at once (see SessionFilter.MaxTracked).
	// session_id comes from the unauthenticated /v1/check, so a client can
	// write a new one on every call; without a cap, summarising a window
	// would hold one record per line.
	DefaultMaxTrackedSessions = 50000
	// MaxSessionAgents and MaxSessionTransports cap a summary's lists, for
	// the same reason: agent_id and meta values are caller-chosen. The
	// first ones seen are kept and ListsTruncated is set.
	MaxSessionAgents     = 32
	MaxSessionTransports = 16
)
View Source
const (
	AnchorMatch        = "match"
	AnchorMismatch     = "mismatch"
	AnchorMissing      = "missing"       // the log ends before this seq: entries were removed
	AnchorNotCheckable = "not_checkable" // this seq is in the pruned part of the log
	AnchorNotChecked   = "not_checked"   // the chain breaks before this seq
)

Anchor check outcomes, as reported in AnchorResult.Status.

View Source
const ArchiveTimestampFormat = "20060102T150405Z"

ArchiveTimestampFormat is the format used to suffix archived audit files. Lexicographic order matches chronological order, which lets prune walk os.ReadDir without parsing each name back to a time.Time.

View Source
const CheckpointSuffix = ".replay-checkpoint"

CheckpointSuffix is appended to an audit log path to produce the companion checkpoint file written by the startup replay seeder.

View Source
const CurrentSchemaVersion = 2

CurrentSchemaVersion is the schema version produced by this binary. Readers accept N-1 (headerless v1 files written by v0.4.0) transparently and refuse N+1 with a clear error.

View Source
const DefaultFilePermissions = 0600

DefaultFilePermissions is the Unix file mode for newly created audit log files. Restricted to owner-only since audit logs may contain sensitive operational data.

View Source
const SubagentMetaKey = "subagent"

SubagentMetaKey is the request meta key that names a sub-agent. The Claude Code hook sets it to the sub-agent's type, so the calls a sub-agent makes inside its parent's session can be told apart.

View Source
const TransportLLMAPIProxy = "llm_api_proxy"

TransportLLMAPIProxy is the canonical transport string for entries produced via the LLM API Proxy (cmd/agentguard-llm-proxy).

View Source
const TransportMCPGateway = "mcp_gateway"

TransportMCPGateway is the canonical transport string for entries produced via the MCP Gateway (cmd/agentguard-mcp-gateway).

View Source
const TransportSDK = "sdk"

TransportSDK is the canonical transport string for SDK callers (Python / TypeScript) that do not stamp meta["transport"]. Older audit entries also default to this value via EffectiveTransport.

Variables

This section is empty.

Functions

func CheckpointPath added in v1.1.0

func CheckpointPath(auditPath string) string

CheckpointPath returns the companion checkpoint file path for an audit log: `<audit-log-path>` + CheckpointSuffix. Every reader and writer of the checkpoint — the server's startup seeder, the `agentguard migrate` subcommand, and the startup migration that invalidates it — MUST derive the path through this function so they can never disagree about where the file lives.

func LoadSealKey added in v1.4.0

func LoadSealKey(path string, strictPerms bool) ([]byte, error)

LoadSealKey reads an HMAC seal key file. The key is the file's content with surrounding whitespace removed, so a hex key written by `agentguard setup` or `openssl rand -hex 32 > key` works as is. With strictPerms (the server), a key file that other users can read is refused on Unix: anyone holding the key can forge a valid chain.

func LogFiles added in v1.4.0

func LogFiles(livePath string) ([]string, error)

LogFiles returns the audit log at livePath and the rotated archives (gzip or plain) that link back to it through _meta.rotated_from, oldest first: the files VerifyChain checks. An archive that no file links to is left out; VerifyChain reports it. The error is for a live file that can't be read.

func ReadEntries added in v1.4.0

func ReadEntries(path string, fn func(Entry)) (bad int, err error)

ReadEntries calls fn for every entry of one audit file (the live file or a rotated archive, gzip or plain), in file order. It skips blank and _meta lines and returns how many lines were not valid entries; nothing is logged for them. The error is for a file that can't be opened or read to the end (a damaged gzip stream, say); the entries before that point have already been passed to fn.

func ReplayFrom added in v0.5.0

func ReplayFrom(auditPath string, cp *Checkpoint, fn func(Entry)) (int64, error)

ReplayFrom scans auditPath starting at cp.Offset (or 0 if cp is nil or stale) and invokes fn for every valid entry. Returns the end-of-file offset reached during the scan — the caller should persist this back as the next checkpoint so subsequent boots skip work already done.

Staleness detection: if cp.AuditSize exceeds the current file size, the file has been truncated or rotated; we discard the checkpoint and rescan from the beginning so counters stay accurate.

Missing audit file: returns (0, nil) — a fresh install has nothing to replay, and that is not an error.

ReplayFrom is the pre-1.1.0 primitive and is kept unchanged for embedders. It knows nothing about rotation or persisted counts; the server uses ReplayWithCheckpoint.

func SealKeyID added in v1.4.0

func SealKeyID(key []byte) string

SealKeyID returns the key's public identifier: the hex of the first 8 bytes of SHA-256(key).

func SortSessions added in v1.4.0

func SortSessions(list []SessionSummary)

SortSessions orders sessions newest first: by LastSeen descending, then by SessionID so sessions that ended at the same instant keep one order.

func WriteCheckpoint added in v0.5.0

func WriteCheckpoint(auditPath string, cp Checkpoint) error

WriteCheckpoint persists cp atomically via write-then-rename. A partial write crash therefore leaves either the old checkpoint or no change — never a half-written file.

Types

type Anchor added in v1.4.0

type Anchor struct {
	SegmentID string    `json:"segment_id"`
	Seq       uint64    `json:"seq"`
	Hash      string    `json:"hash"`
	At        time.Time `json:"at"`
}

Anchor is a published head of the chain: the entry with this seq has this hash. Copied somewhere the audit host cannot rewrite (a SIEM, a ticket), it lets `agentguard audit verify --anchor seq:hash` prove that nothing up to seq was changed or removed afterwards, even by someone holding the key.

type AnchorResult added in v1.4.0

type AnchorResult struct {
	Seq    uint64 `json:"seq"`
	Hash   string `json:"hash"`
	Status string `json:"status"`
	Found  string `json:"found,omitempty"` // the hash the log has for Seq, on a mismatch
}

AnchorResult is the outcome of one --anchor.

type BufferedAsyncLogger added in v0.5.0

type BufferedAsyncLogger struct {
	// contains filtered or unexported fields
}

BufferedAsyncLogger wraps an underlying Logger with a bounded queue, worker pool, and disk-overflow spill path. See package doc for the concurrency contract.

func NewBufferedAsyncLogger added in v0.5.0

func NewBufferedAsyncLogger(underlying Logger, opts BufferedAsyncOpts) (*BufferedAsyncLogger, error)

NewBufferedAsyncLogger constructs a buffered async wrapper around underlying. The parent directory of OverflowPath is created if missing (mode 0700) so the operator does not have to pre-provision it.

func (*BufferedAsyncLogger) Close added in v0.5.0

func (b *BufferedAsyncLogger) Close() error

Close drains the queue and stops all goroutines. Safe to call multiple times (sync.Once). Close also runs a one-shot best-effort overflow drain bounded by closeFlushTimeout so a small backlog at shutdown still reaches the underlying logger.

Close does NOT close the underlying logger — the caller owns that lifecycle (typically `defer logger.Close()` in cmd/agentguard/main.go, after the buffered wrapper's defer fires).

We DO NOT close b.queue: with concurrent Log() callers in flight, a `close(channel)` would race a `chansend`. Instead we signal shutdown via b.closed and let workers exit on a `select` that picks up either a queued entry or the close signal. Log() also reads b.closed first and routes to the overflow file once shutdown has begun.

func (*BufferedAsyncLogger) DrainedFromOverflow added in v0.5.0

func (b *BufferedAsyncLogger) DrainedFromOverflow() uint64

DrainedFromOverflow returns the lifetime count of entries the recovery goroutine successfully pushed back into the queue from the overflow file.

func (*BufferedAsyncLogger) DroppedToOverflow added in v0.5.0

func (b *BufferedAsyncLogger) DroppedToOverflow() uint64

DroppedToOverflow returns the lifetime count of entries that were spilled to the overflow file because the queue was full or the underlying Log call returned an error.

func (*BufferedAsyncLogger) EnableCheckpoints added in v1.2.0

func (b *BufferedAsyncLogger) EnableCheckpoints(initial DecisionCounts, atOffset int64) bool

EnableCheckpoints forwards to the underlying logger.

func (*BufferedAsyncLogger) Log added in v0.5.0

func (b *BufferedAsyncLogger) Log(e Entry) error

Log enqueues an entry for async write. Never blocks: on saturation the entry is appended to the overflow file and dropToOverflow is incremented. Returns an error only when both legs of durability fail (queue full AND overflow append failed), which means the entry is lost.

After Close has been called, Log spills directly to the overflow file rather than touching the (now-closed) queue. This is the documented behavior for the racy "Log called concurrently with Close" path: the entry is durable on disk and a future process startup will pick it up (once the recovery loop is reinstated, which is the operator's restart-the-binary remediation).

func (*BufferedAsyncLogger) Path added in v1.1.0

func (b *BufferedAsyncLogger) Path() string

Path returns the audit file path of the wrapped logger when it is file-backed (a *FileLogger, or anything else exposing Path()), and "" otherwise. The server's startup seeder keys its replay checkpoint off this: without it the production pipeline — which always wraps the FileLogger in a BufferedAsyncLogger — could never resume from a checkpoint and re-scanned the whole live file on every boot.

func (*BufferedAsyncLogger) Query added in v0.5.0

func (b *BufferedAsyncLogger) Query(filter QueryFilter) ([]Entry, error)

Query passes through to the underlying logger. Entries currently in the overflow spill file are NOT yet visible to the index; the recovery goroutine closes that gap within RecoveryInterval.

func (*BufferedAsyncLogger) QueueDepth added in v0.5.0

func (b *BufferedAsyncLogger) QueueDepth() int

QueueDepth returns the approximate number of entries sitting in the in-memory queue. Returned value is an atomic load of an int64 counter maintained by Log() / workerLoop(); it can briefly drift past the channel's actual len at a few hundred RPS but converges quickly.

func (*BufferedAsyncLogger) SealHead added in v1.4.0

func (b *BufferedAsyncLogger) SealHead() (Anchor, bool)

SealHead forwards to the underlying logger. Entries still in the queue are not part of the chain yet, so the head can trail the newest request.

func (*BufferedAsyncLogger) SessionDetail added in v1.4.0

SessionDetail forwards to the underlying logger.

func (*BufferedAsyncLogger) Sessions added in v1.4.0

Sessions forwards to the underlying logger. Like Query, it does not see entries still in the queue or the overflow file.

type BufferedAsyncOpts added in v0.5.0

type BufferedAsyncOpts struct {
	QueueSize        int           // default 1024 if 0
	Workers          int           // default 4   if 0
	OverflowPath     string        // required (caller derives, e.g. "<auditPath>.overflow.jsonl")
	RecoveryInterval time.Duration // default 5s if 0; mainly exposed for tests
	// Transform, when set, is applied to an entry before it is appended to
	// the overflow file, so the spill file holds the same (e.g. redacted)
	// content as the underlying log. Wrap the underlying logger with
	// WithTransform for the normal worker path.
	Transform EntryTransform
}

BufferedAsyncOpts configures NewBufferedAsyncLogger.

Zero values for QueueSize, Workers, and RecoveryInterval are replaced with documented defaults. OverflowPath is required — the caller picks the location (typically `<auditPath>.overflow.jsonl`) so the operator's retention/backup tooling sees the spill file alongside the live log.

type Checkpoint added in v0.5.0

type Checkpoint struct {
	Offset    int64           `json:"offset"`
	AuditSize int64           `json:"audit_size"`
	FileID    string          `json:"file_id,omitempty"`
	Counts    *DecisionCounts `json:"counts,omitempty"`
	LastSeq   uint64          `json:"last_seq,omitempty"`
	LastHash  string          `json:"last_hash,omitempty"`
}

Checkpoint records how far the startup seeder scanned into the audit log, so the next boot can resume from the stored offset instead of re-reading gigabytes of history.

Offset and AuditSize describe the live audit file at the time the checkpoint was written. FileID identifies WHICH file they describe: it is the live file's schema-v2 `_meta.segment_id` (or, for a file written before v1.4, its `_meta.created_at`), which rotation rewrites for every new live file. A boot whose live file carries a different FileID knows the log rotated in between and follows the `_meta.rotated_from` chain back to the checkpointed segment rather than trusting a byte offset into an unrelated file. FileID is empty for legacy headerless files, in which case the older size heuristic applies (see ReplayWithCheckpoint).

Counts is the cumulative decision tally as of Offset. It is what makes the counters restart-safe. A checkpoint without Counts (written by a pre-1.1.0 binary) triggers one full replay so the lifetime totals are re-established before the next checkpoint is written.

Both new fields are additive and omitted when empty, so an older binary reading a newer checkpoint sees exactly the shape it always did.

LastSeq and LastHash (v1.4, sealed logs only) are the head of the seal chain when the checkpoint was written. At startup the FileLogger compares them with the end of the live file: a file that ends before them lost entries. Also additive and omitted when empty.

func ReadCheckpoint added in v0.5.0

func ReadCheckpoint(auditPath string) (*Checkpoint, error)

ReadCheckpoint loads the checkpoint for auditPath. Returns (nil, nil) when the checkpoint file is missing — this is the expected first-boot state and must not be treated as an error. A corrupt checkpoint is also downgraded to (nil, nil) so an unreadable marker simply triggers a full rescan rather than aborting startup.

func ReplayWithCheckpoint added in v1.1.0

func ReplayWithCheckpoint(auditPath string, cp *Checkpoint, fn func(Entry)) (Checkpoint, error)

ReplayWithCheckpoint is the restart-safe startup replay. It scans every audit entry written after cp (or every entry, when cp is nil or predates persisted counts), invokes fn for each, and returns the Checkpoint the caller should persist for the next boot.

The returned Checkpoint.Counts is the LIFETIME decision tally: cp.Counts carried forward plus every entry replayed in this call. Seeding the decision counters from it — rather than incrementing per replayed entry — is what makes `agentguard_checks_total` and `/api/stats` survive restarts with the same totals a never-restarted process would show.

Resume rules, in order:

  1. cp nil, or cp without Counts (legacy checkpoint): full replay of the live file from byte 0. The one-time cost re-establishes the lifetime tally; every later boot resumes.
  2. cp.FileID set and equal to the live file's identity (`_meta.segment_id`, or `_meta.created_at` before v1.4; see fileIDOf): same file, resume at cp.Offset.
  3. cp.FileID set but different: the log rotated since the checkpoint. Walk `_meta.rotated_from` from the live file back through the archives (gzip or plain). The archive whose header matches cp.FileID is replayed from cp.Offset; every newer archive and the live file are replayed from 0, oldest first. If the chain ends before a match (the checkpointed segment was pruned), everything reachable is replayed from 0 and cp.Counts is still carried forward — the pruned entries were counted before they were pruned.
  4. cp.FileID empty (headerless legacy live file): the size heuristic — resume at cp.Offset when the file has only grown, else full replay.

Missing audit file: returns a zero Checkpoint and nil — a fresh install has nothing to replay. A scan error on any segment is logged, the remaining segments are still replayed, and the first error is returned alongside the (partial) checkpoint so the caller can decide whether to persist it.

type Checkpointer added in v1.2.0

type Checkpointer interface {
	// EnableCheckpoints starts checkpoint maintenance from the lifetime tally
	// initial, which covers the live file up to atOffset. It reports whether
	// the logger (or the logger it wraps) supports it.
	EnableCheckpoints(initial DecisionCounts, atOffset int64) bool
}

Checkpointer is implemented by loggers that can keep the replay checkpoint current themselves (FileLogger, and the wrappers that forward to one).

type DecisionCounts added in v1.1.0

type DecisionCounts struct {
	Total           uint64 `json:"total"`
	Allow           uint64 `json:"allow"`
	Deny            uint64 `json:"deny"`
	RequireApproval uint64 `json:"require_approval"`
}

DecisionCounts is the cumulative decision tally recorded in a Checkpoint. It is what lets the decision counters on /metrics and /api/stats survive a restart: the next boot seeds the counters from these values and then replays only the entries written after Offset.

Total counts every replayed entry; the three named fields count the well-known decisions. Total - (Allow+Deny+RequireApproval) is the number of entries carrying an unknown decision string (see Other).

func (DecisionCounts) Other added in v1.1.0

func (c DecisionCounts) Other() uint64

Other returns the number of counted entries whose decision was none of the three well-known values.

type Entry

type Entry struct {
	Timestamp time.Time `json:"timestamp"`
	// TenantID is the tenant the action was evaluated against. Empty on the
	// wire for the default "local" tenant (and for entries written by binaries
	// predating multi-tenancy) — readers MUST resolve it via EffectiveTenant().
	// The `omitempty` tag keeps single-tenant ("local") output byte-identical
	// to files written before multi-tenancy, so byte-identity fixtures stay
	// green; the field is additive and does NOT bump the audit
	// schema_version (still 2), exactly like Transport.
	TenantID   string               `json:"tenant_id,omitempty"`
	SessionID  string               `json:"session_id"`
	AgentID    string               `json:"agent_id"`
	Request    policy.ActionRequest `json:"request"`
	Result     policy.CheckResult   `json:"result"`
	DurationMs int64                `json:"duration_ms"`
	Transport  string               `json:"transport,omitempty"`
	// Seq, PrevHash and Hash are the tamper-evident seal (v1.4, written only
	// with --audit-seal; see seal.go). The FileLogger sets them; whatever a
	// caller puts here is overwritten, or cleared when sealing is off. Hash
	// must stay the last field: it is computed over the line without it.
	// Additive and omitted when empty; schema_version stays 2.
	Seq      uint64 `json:"seq,omitempty"`
	PrevHash string `json:"prev_hash,omitempty"`
	Hash     string `json:"hash,omitempty"`
}

Entry represents a single audit log record.

Transport identifies the integration path that produced this entry. One of "sdk" (default; SDK call), "mcp_gateway" (MCP Gateway tool call), "llm_api_proxy" (LLM API Proxy), or another gateway-defined value. Empty on unmarshal of older entries; readers MUST default to EffectiveTransport() when consuming the field. The field is additive and does NOT bump the audit schema_version (still 2). See docs/PROXY_ARCHITECTURE.md § "Audit transport tag".

func (Entry) EffectiveTenant added in v0.9.0

func (e Entry) EffectiveTenant() string

EffectiveTenant returns e.TenantID if set, otherwise the default "local" tenant. Use when reading audit entries that may have been written by a single-tenant binary (no tenant_id on disk) or for the default tenant (which is stored as "" to preserve byte-identity).

func (Entry) EffectiveTransport added in v0.5.0

func (e Entry) EffectiveTransport() string

EffectiveTransport returns e.Transport if set, otherwise the SDK default. Use when reading audit entries that may have been written by an older binary (no Transport field on disk).

type EntryTransform added in v1.2.0

type EntryTransform func(Entry) Entry

EntryTransform rewrites an entry before it is stored — for example to mask secrets. It must return a copy and never modify maps it shares with the caller.

type FileLogger

type FileLogger struct {
	// contains filtered or unexported fields
}

FileLogger writes audit entries as JSON lines to a file.

Rotation: when rotCfg.MaxSize > 0, every Log() call stats the underlying file after the write and hands off to rotateLocked() once the live file meets or exceeds the threshold. Rotation is opt-in (wired via NewFileLoggerWithRotation); callers using the zero-rotation NewFileLogger get unbounded growth.

func NewFileLogger

func NewFileLogger(path string) (*FileLogger, error)

NewFileLogger creates a new file-based audit logger.

Schema v2: if the target file does not exist or is empty, the logger writes a single {"_meta":{"schema_version":2,...}} header line before any entries. Existing non-empty files are left alone — the pkg/migrate/v040_to_v041 package rewrites legacy (headerless, v1) files. Query() tolerates both cases transparently.

func NewFileLoggerWithOptions added in v1.4.0

func NewFileLoggerWithOptions(path string, opts FileLoggerOptions) (*FileLogger, error)

NewFileLoggerWithOptions is NewFileLogger with rotation and sealing.

With opts.Seal set, a new file's header carries the seal fields. An existing file continues the chain its last sealed entry left off (or starts one, when it has none): the logger reads the file's end, warns if it ends before the seq the replay checkpoint recorded, and appends a `_meta` line saying how the entries after it are sealed.

func NewFileLoggerWithRotation added in v0.5.0

func NewFileLoggerWithRotation(path string, cfg RotationConfig) (*FileLogger, error)

NewFileLoggerWithRotation is NewFileLogger + rotation policy. The configuration is captured at construction; mutating it after the fact has no effect.

func (*FileLogger) Close

func (l *FileLogger) Close() error

Close flushes and closes the log file.

func (*FileLogger) EnableCheckpoints added in v1.2.0

func (l *FileLogger) EnableCheckpoints(initial DecisionCounts, atOffset int64) bool

EnableCheckpoints makes the FileLogger maintain <path>.replay-checkpoint from now on. It counts every entry it writes on top of initial (the tally the startup replay produced through atOffset, including entries appended since, e.g. by an overflow drain) and writes the checkpoint after every rotation — before old archives are pruned — and on Close.

Without it the checkpoint is only written at startup; once rotation prunes the file it points to, the next startup can only recount the files that are left.

func (*FileLogger) Log

func (l *FileLogger) Log(entry Entry) error

Log writes an audit entry to the log file.

When RotationConfig.MaxSize is non-zero, the underlying file is stat'd after a successful encode and rotateLocked() fires if the live file has reached the size threshold. Rotation errors are returned to the caller because the entry that triggered rotation has already been persisted — a rotation failure here is a signal the operator needs to act on, not a write that silently dropped data.

func (*FileLogger) Path added in v0.5.0

func (l *FileLogger) Path() string

Path returns the filesystem path of the underlying audit log, or "" if the FileLogger has no open file. Used by callers that need to co-locate auxiliary files (checkpoint, rotation markers) next to the log.

func (*FileLogger) Query

func (l *FileLogger) Query(filter QueryFilter) ([]Entry, error)

Query reads the log file and filters entries.

Lock scope: we hold l.mu only long enough to capture the current file path (effectively immutable, but we still snapshot it under the lock for safety) and open a read handle. The actual scan runs WITHOUT the lock so concurrent Log() writes are not blocked by long queries.

This is safe because:

  • FileLogger.file is opened in O_APPEND mode, so writes go to EOF atomically (on POSIX, append writes of ≤PIPE_BUF are atomic).
  • Our read handle captures a consistent size at open; extra bytes written after we open are simply not seen by this query.
  • Scanner discards partial lines implicitly (each line terminated by \n).

Performance note: Query scans the full file linearly — acceptable for the file backend's deployment profile. Workloads with large audit logs should run the indexed store backend instead (`--persist --audit-backend=store`, SQL-side filtering on audit_entries); the two backends' QueryFilter semantics are pinned by pkg/store/audit_query_parity_test.go.

func (*FileLogger) SealHead added in v1.4.0

func (l *FileLogger) SealHead() (Anchor, bool)

SealHead reports the head of the chain: the last sealed entry's seq, hash and segment. ok is false when sealing is off. It takes the logger's lock briefly and does no I/O.

func (*FileLogger) SessionDetail added in v1.4.0

func (l *FileLogger) SessionDetail(f SessionDetailFilter) (SessionDetail, error)

SessionDetail reads one session in at most two passes over the file: the first collects the session's summary, its entries and the ids of the sessions that name it as parent; the second, only when there are such sessions, summarises them.

func (*FileLogger) Sessions added in v1.4.0

func (l *FileLogger) Sessions(f SessionFilter) (SessionPage, error)

Sessions summarises the live file's sessions in a bounded scan (see summarize and SessionPage).

func (*FileLogger) StartAnchoring added in v1.4.0

func (l *FileLogger) StartAnchoring(interval time.Duration) (stop func())

StartAnchoring publishes an anchor every interval (when something was sealed since the last one) until stop is called. Anchors are also published at each rotation and on Close. It is a no-op when sealing is off or interval <= 0. The ticker runs on its own goroutine and only takes the logger's lock for an in-memory read: no anchor work reaches the /v1/check path.

type FileLoggerOptions added in v1.4.0

type FileLoggerOptions struct {
	Rotation RotationConfig
	// Seal, when set, seals every entry into a hash chain (see seal.go).
	Seal *SealConfig
}

FileLoggerOptions configures NewFileLoggerWithOptions. The zero value is NewFileLogger: no rotation, no sealing.

type Logger

type Logger interface {
	Log(entry Entry) error
	Query(filter QueryFilter) ([]Entry, error)
	Close() error
}

Logger is the interface for audit logging.

func WithTransform added in v1.2.0

func WithTransform(inner Logger, t EntryTransform) Logger

WithTransform wraps a Logger so every entry is passed through t before it is written. Query and Close pass through. Path is forwarded when inner has one, so the replay checkpoint keeps working behind the wrapper.

type MetaRecord added in v0.5.0

type MetaRecord struct {
	SchemaVersion int       `json:"schema_version"`
	CreatedAt     time.Time `json:"created_at"`
	SegmentID     string    `json:"segment_id,omitempty"`
	RotatedFrom   string    `json:"rotated_from,omitempty"`
	Seal          *SealInfo `json:"seal,omitempty"`
	ChainPrev     string    `json:"chain_prev,omitempty"`
	ChainSeq      uint64    `json:"chain_seq,omitempty"`
	NodeID        string    `json:"node_id,omitempty"`
}

MetaRecord is the shape of the first line of a schema-v2 audit file. Optional RotatedFrom is set when the file is the successor in a rotation chain — the replay walker follows it to stitch history back together.

SegmentID (since v1.4) is a random identifier for this file, used as the checkpoint's FileID. CreatedAt alone is not unique: on a coarse clock two files created in quick succession (a rotation right after startup, or two rotations in a row) can share a timestamp, and replay then resumed in the wrong file. Files written before v1.4 have no SegmentID; fileIDOf falls back to CreatedAt for them, so existing checkpoints keep matching.

The seal fields (since v1.4) are set only when the server runs with --audit-seal (see seal.go). Seal says how the entries after this line are sealed; ChainPrev and ChainSeq are the hash and seq of the last sealed entry before this line (in the previous file, for a header written at rotation), which is what links the files of a chain together; NodeID is the writing server's --node-id. A `_meta` line with these fields can also appear in the middle of a file: the server writes one when it starts sealing an existing file. All four are omitted when sealing is off.

func NewMetaRecord added in v1.4.0

func NewMetaRecord(rotatedFrom string) MetaRecord

NewMetaRecord returns the header for a new audit file: the current schema version, the creation time, a fresh SegmentID, and rotatedFrom (the predecessor's base name, or "" for a file that starts a chain).

func ReadMeta added in v0.5.0

func ReadMeta(path string) (*MetaRecord, error)

ReadMeta returns the schema-v2 meta record from the first line of path. Returns (nil, nil) if the file is headerless (v0.4.0 legacy format) or empty, so callers can distinguish "no header" from "read error".

Refuses files whose schema_version is newer than CurrentSchemaVersion — running an old binary against a newer file is an obvious operator error.

type QueryFilter

type QueryFilter struct {
	// TenantID scopes the query to one tenant. Compared against
	// Entry.EffectiveTenant so entries stored with an empty tenant_id (the
	// default "local" tenant, and entries predating multi-tenancy) match the value "local".
	// Empty disables the filter (returns all tenants — operator/global view).
	TenantID  string `json:"tenant_id,omitempty"`
	AgentID   string `json:"agent_id,omitempty"`
	SessionID string `json:"session_id,omitempty"`
	Decision  string `json:"decision,omitempty"`
	Scope     string `json:"scope,omitempty"`
	// Since keeps entries at or after this time (inclusive); Until keeps
	// entries before it (exclusive, v1.4). Together they page a time range
	// without overlap: [since, until) followed by [until, next) sees every
	// entry once.
	Since  *time.Time `json:"since,omitempty"`
	Until  *time.Time `json:"until,omitempty"`
	Limit  int        `json:"limit,omitempty"`
	Offset int        `json:"offset,omitempty"`
	// ParentSessionID keeps entries whose request named this parent
	// session (v1.4): the calls of the sessions started by it.
	ParentSessionID string `json:"parent_session_id,omitempty"`
	// Transport filters by the integration path that produced the
	// entry ("sdk", "mcp_gateway", "llm_api_proxy"). Compared against
	// Entry.EffectiveTransport so older entries (no Transport on
	// disk) match the "sdk" value. Empty disables the filter.
	Transport string `json:"transport,omitempty"`
	// Desc returns the newest matches first (v1.2). Offset then counts from
	// the newest entry. The default (false) keeps the original oldest-first
	// order.
	Desc bool `json:"desc,omitempty"`
}

QueryFilter specifies criteria for querying audit logs.

Offset is applied after filtering but before the Limit is reached: the first Offset matching records are discarded, then up to Limit records are collected. A Limit of 0 means "no cap".

type RotationConfig added in v0.5.0

type RotationConfig struct {
	// MaxSize is the live-file size threshold in bytes. 0 disables rotation.
	MaxSize int64
	// MaxFiles caps the number of archived files kept after pruning. Older
	// archives (by timestamp suffix) are deleted first. 0 means keep all.
	MaxFiles int
	// MaxAge bounds the age of archived files. Archives older than this
	// are deleted at rotation time. Zero disables age-based pruning. Age
	// is computed against the timestamp suffix on the archive name, not
	// against filesystem mtime, so an out-of-band rsync of archives does
	// not silently invalidate retention.
	MaxAge time.Duration
	// Compress, when true, gzips the archive file after rename and removes
	// the uncompressed copy. A gzip failure leaves the uncompressed archive
	// in place — pruning still counts it.
	Compress bool
}

RotationConfig configures size-triggered rotation for FileLogger.

The zero value disables rotation entirely.

When MaxSize > 0, after every successful Log() the current file size is checked and rotateLocked() fires if the file meets or exceeds MaxSize. Rotation is synchronous; the caller's Log() blocks until the rename + new-file + header write is done. Expected cost: one stat + one rename + one file open + ~80 bytes encode. At default 100 MB thresholds this is milliseconds every few hundred thousand entries.

type SealConfig added in v1.4.0

type SealConfig struct {
	// Key is the HMAC-SHA256 key. Empty means a plain SHA-256 chain.
	Key []byte
	// NodeID is written into each header as node_id (the server's
	// --node-id), so a verifier can tell which node wrote a file.
	NodeID string
}

SealConfig turns sealing on for a FileLogger.

type SealHeader added in v1.4.0

type SealHeader interface {
	SealHead() (a Anchor, ok bool)
}

SealHeader is implemented by loggers that can report the head of a sealed chain (FileLogger, and the wrappers that forward to one). ok is false when sealing is off.

type SealInfo added in v1.4.0

type SealInfo struct {
	Alg   string `json:"alg"`
	KeyID string `json:"key_id,omitempty"`
}

SealInfo is the `seal` object of a `_meta` line: how the entries after it are sealed. KeyID identifies the HMAC key without revealing it (see SealKeyID); it is empty for plain SHA-256.

type SealKeyNeededError added in v1.4.0

type SealKeyNeededError struct {
	KeyID string
	File  string
}

SealKeyNeededError is returned when the log is sealed with an HMAC key and no key was given: nothing can be verified without it.

func (*SealKeyNeededError) Error added in v1.4.0

func (e *SealKeyNeededError) Error() string

type SessionAgent added in v1.4.0

type SessionAgent struct {
	AgentID  string `json:"agent_id"`
	Subagent string `json:"subagent,omitempty"`
}

SessionAgent is one agent seen in a session: an agent_id, and the sub-agent (meta["subagent"]) when the calls came from one.

type SessionDetail added in v1.4.0

type SessionDetail struct {
	Found             bool
	Session           SessionSummary
	Children          []SessionSummary
	ChildrenTruncated bool
	Entries           []Entry
	Truncated         bool
}

SessionDetail is one session: its summary (Found is false when it has no entry in the window), the sessions it started, and its entries in log order. Truncated says the session had more than EntryLimit entries; ChildrenTruncated, that not every child session is listed.

func QuerySessionDetail added in v1.4.0

func QuerySessionDetail(l Logger, f SessionDetailFilter) (SessionDetail, error)

QuerySessionDetail returns one session's detail through l. Without a SessionDetailer it runs three queries: the summary, the children and the entries (on the SQL store each is an indexed query).

type SessionDetailFilter added in v1.4.0

type SessionDetailFilter struct {
	TenantID   string
	SessionID  string
	Since      *time.Time
	Until      *time.Time
	EntryLimit int
	ChildLimit int
}

SessionDetailFilter selects one session for QuerySessionDetail. EntryLimit and ChildLimit bound the entries and child sessions returned.

type SessionDetailer added in v1.4.0

type SessionDetailer interface {
	SessionDetail(f SessionDetailFilter) (SessionDetail, error)
}

SessionDetailer is implemented by loggers that read a session's detail in fewer passes than QuerySessionDetail's three queries.

type SessionFilter added in v1.4.0

type SessionFilter struct {
	TenantID        string
	SessionID       string
	ParentSessionID string
	AgentID         string
	Since           *time.Time
	Until           *time.Time
	Limit           int
	MaxTracked      int
}

SessionFilter selects the sessions SessionQuerier.Sessions returns.

Since (inclusive) and Until (exclusive) bound the entries that are counted, as in QueryFilter; nil leaves that side open. Entries with an empty session_id belong to no session and are never counted.

AgentID keeps sessions in which that agent made a call in the window; ParentSessionID keeps sessions with an entry naming that parent. Both select sessions, they do not trim them: the summary still covers every entry of the session in the window. Limit 0 means no cap.

MaxTracked bounds how many sessions a scan holds in memory (file backend); 0 means max(10*Limit, DefaultMaxTrackedSessions). Past it the result is marked Truncated (see SessionPage).

type SessionPage added in v1.4.0

type SessionPage struct {
	Sessions  []SessionSummary
	Truncated bool
}

SessionPage is the result of a session query.

Truncated is true when the window held more sessions than the scan tracks (SessionFilter.MaxTracked). The sessions returned are then the ones most recently written to the log, with exact summaries; older sessions, and with an AgentID or ParentSessionID filter some matching ones, may be missing. A narrower since/until avoids it. The SQL store backend never truncates.

func QuerySessions added in v1.4.0

func QuerySessions(l Logger, f SessionFilter) (SessionPage, error)

QuerySessions summarises sessions through l: natively when l is a SessionQuerier, otherwise from the entries l.Query returns.

type SessionQuerier added in v1.4.0

type SessionQuerier interface {
	Sessions(f SessionFilter) (SessionPage, error)
}

SessionQuerier is implemented by loggers that summarise sessions themselves: the FileLogger in a bounded scan of the file, the store-backed logger in SQL, and the wrappers that forward to either. Sessions are returned newest first (by last_seen, then session_id).

type SessionSummary added in v1.4.0

type SessionSummary struct {
	SessionID       string         `json:"session_id"`
	ParentSessionID string         `json:"parent_session_id,omitempty"`
	Agents          []SessionAgent `json:"agents"`
	Transports      []string       `json:"transports"`
	FirstSeen       time.Time      `json:"first_seen"`
	LastSeen        time.Time      `json:"last_seen"`
	Counts          DecisionCounts `json:"counts"`
	ListsTruncated  bool           `json:"lists_truncated,omitempty"`
}

SessionSummary describes one session over a time window: who took part, through which transports, when, and how its checks were decided.

ParentSessionID is the parent named by the session's first entry that names one. A session that names several parents is still listed under each of them by a ParentSessionID filter.

ListsTruncated is true when the session had more than MaxSessionAgents agents or MaxSessionTransports transports; the lists hold the first ones seen. The counts always cover every entry.

func (*SessionSummary) AddAgent added in v1.4.0

func (s *SessionSummary) AddAgent(agentID, subagent string)

AddAgent records an agent in the summary, up to MaxSessionAgents distinct ones. An entry with neither an agent nor a sub-agent adds none.

func (*SessionSummary) AddTransport added in v1.4.0

func (s *SessionSummary) AddTransport(t string)

AddTransport records a transport in the summary, up to MaxSessionTransports distinct ones.

func (*SessionSummary) SortLists added in v1.4.0

func (s *SessionSummary) SortLists()

SortLists puts Agents (by agent_id, then subagent) and Transports in a stable order, so every backend returns the same JSON for the same data.

type VerifyBreak added in v1.4.0

type VerifyBreak struct {
	File   string `json:"file,omitempty"`
	Line   int    `json:"line,omitempty"`
	Reason string `json:"reason"`
}

VerifyBreak is the first place the chain does not hold.

type VerifyOptions added in v1.4.0

type VerifyOptions struct {
	// Keys are the HMAC keys the log may be sealed with, matched by key id.
	// More than one is needed only when the key was changed over time.
	Keys [][]byte
	// Anchors are published heads to check: the entry with Anchor.Seq must
	// have Anchor.Hash. Other Anchor fields are ignored.
	Anchors []Anchor
	// WithoutKey checks a log sealed with an HMAC key when Keys is empty,
	// instead of returning *SealKeyNeededError: everything but the hashes
	// of the HMAC-sealed entries (every entry after a seal is sealed, seq
	// has no gaps, each prev_hash is the hash before it, the files link
	// up). VerifyReport.HashesUnchecked then says the hashes were skipped.
	// Anyone can write a chain that passes this; it catches entries added,
	// removed or reordered by hand, not a rewrite.
	WithoutKey bool
}

VerifyOptions configures VerifyChain.

type VerifyReport added in v1.4.0

type VerifyReport struct {
	// OK is true when every sealed entry verified, the files link up, no
	// anchor mismatched or is missing, and, when the log has entries, at
	// least one of them is sealed. An unsealed log is not OK and has no
	// Break: nothing in it was checked.
	OK bool `json:"ok"`
	// Segments are the files walked, oldest first (base names).
	Segments []string `json:"segments"`
	// Entries is the number of sealed entries verified; FirstSeq..LastSeq
	// their range, LastHash the hash of the last one.
	Entries  uint64 `json:"entries"`
	FirstSeq uint64 `json:"first_seq,omitempty"`
	LastSeq  uint64 `json:"last_seq,omitempty"`
	LastHash string `json:"last_hash,omitempty"`
	// PrunedBefore is set when the oldest surviving file continues a chain
	// whose earlier files were pruned: the chain starts at PrunedBefore+1.
	PrunedBefore uint64 `json:"pruned_before,omitempty"`
	// Unsealed counts entries written before sealing began. They are not
	// protected by the chain.
	Unsealed uint64 `json:"unsealed"`
	// Seals lists how the entries are sealed ("hmac-sha256 key <id>",
	// "sha256"), in the order they appear.
	Seals []string `json:"seals,omitempty"`
	// HashesUnchecked is true when VerifyOptions.WithoutKey skipped the
	// hashes of entries sealed with a key that was not given.
	HashesUnchecked bool           `json:"hashes_unchecked,omitempty"`
	Warnings        []string       `json:"warnings,omitempty"`
	Break           *VerifyBreak   `json:"break,omitempty"`
	Anchors         []AnchorResult `json:"anchors,omitempty"`
}

VerifyReport is what VerifyChain found.

func VerifyChain added in v1.4.0

func VerifyChain(livePath string, opts VerifyOptions) (*VerifyReport, error)

VerifyChain checks the sealed chain of the audit log at livePath and the rotated archives it links back to (gzip or plain), oldest first.

The returned error is for problems reading the files (missing live file, permissions) and for *SealKeyNeededError. Everything the files themselves get wrong — a changed byte, a missing or reordered line, a cut line, a broken link between files, a corrupt archive — is reported in VerifyReport.Break with OK false.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL