Documentation
¶
Overview ¶
Package audit writes one JSONL record per CLI invocation to an append-only log outside the working tree. Per SECURITY.md, the
Span projection of an audit record. umbra ships no OTel SDK, the way it ships no store SDK: see docs/audit-spans.md.
Index ¶
- Constants
- Variables
- func AtOrAfter(release string) func(Record) bool
- func BuildVersion() string
- func ByVersion(version string) func(Record) bool
- func OutcomeFor(code int) string
- func RedactArgv(argv []string, tier string, p RedactPolicy) []string
- func RedactIdentifiersInString(s string, tier string, p RedactPolicy) string
- func SessionIDFromContext(ctx context.Context) string
- func WithSessionID(ctx context.Context, id string) context.Context
- type CIContext
- type Coordinate
- type EgressRow
- type ProfileDecision
- type Record
- type RedactPolicy
- type Sink
- type SinkFunc
- type Span
- type Writer
- func (w *Writer) Append(r Record) error
- func (w *Writer) Close() error
- func (w *Writer) Preflight() error
- func (w *Writer) SetRedactPolicy(p RedactPolicy)
- func (w *Writer) Wrap(ctx context.Context, base Record, fn func() error) error
- func (w *Writer) WrapHook(ctx context.Context, base Record, fn func() error, onComplete func(*Record)) error
Examples ¶
Constants ¶
const ( EgressAllow = "allow" EgressDeny = "deny" )
Egress decision values.
const ( DecisionAccept = "accept" DecisionReject = "reject" )
Decision values for Record.Decision.
const ( DataSecurityLow = "low" DataSecurityMedium = "medium" DataSecurityHigh = "high" DataSecurityMax = "max" )
Tier values for ProfileDecision.Coordinate.DataSecurity. Mirrored here so the redactor does not import the profile package and can
const ( AttrVerb = "umbra.verb" AttrDecision = "umbra.decision" AttrOutcome = "umbra.outcome" AttrExitCode = "umbra.exit_code" AttrKind = "umbra.kind" AttrRefused = "umbra.refused" AttrSessionID = "umbra.session_id" AttrRepoRoot = "umbra.repo_root" AttrPolicySkip = "umbra.policy_skipped" AttrCacheStatus = "umbra.cache" AttrEgressHosts = "umbra.egress_host_count" )
Span attribute keys, namespaced so a collector can select umbra's rows without matching on a verb name. Stable: a consumer's dashboard queries them.
const ( OutcomeOK = "ok" OutcomeRefused = "refused" OutcomeFailed = "failed" OutcomeInternal = "internal" )
Outcome classes, the distinction the exit-code taxonomy already draws and a log line does not: a refusal is not a failure.
const MaxStderrTailBytes = 2048
MaxStderrTailBytes caps Record.StderrTail so the audit row stays small even when a wrapped tool spews. 2 KiB is enough to carry the last few
const RedactedValue = "[REDACTED]"
RedactedValue is the literal replacement token used everywhere redaction applies. Exported so consumers can grep for it in tests.
const SessionIDEnv = "CLAUDE_SESSION_ID"
SessionIDEnv is the environment variable consulted as the fallback source for Record.SessionID when no context value is present. Parent processes
const UnknownVersion = "(devel)"
UnknownVersion is the Record.Version of a row written by a binary that carries no module version, such as one built from a checkout.
Variables ¶
var ErrPathUnset = errors.New("audit: log path not configured")
ErrPathUnset is returned when Append is called on a Writer with empty Path.
Functions ¶
func AtOrAfter ¶ added in v0.250.0
AtOrAfter selects rows from a build at or after release, a tag like "v0.30.0". A non-semver Version ("dev", "(devel)", empty) cannot be placed, so never matches.
func BuildVersion ¶ added in v0.250.0
func BuildVersion() string
BuildVersion reports the module version linked into this binary, or UnknownVersion when the toolchain recorded none.
func OutcomeFor ¶
OutcomeFor classifies an exit code into the four span outcomes. This is the whole point of the projection: refused and failed are different populations.
func RedactArgv ¶
func RedactArgv(argv []string, tier string, p RedactPolicy) []string
RedactArgv applies the data-security tier to argv. Returns the argv to persist. At "low" the argv is returned unchanged. At
func RedactIdentifiersInString ¶
func RedactIdentifiersInString(s string, tier string, p RedactPolicy) string
RedactIdentifiersInString replaces every IdentifierPatterns match with [REDACTED]. Runs at "medium" and stricter. Empty input or
func SessionIDFromContext ¶
SessionIDFromContext returns the session id attached to ctx via WithSessionID, or empty string if none is set. Nil-safe.
Types ¶
type CIContext ¶
type CIContext struct {
Provider string `json:"provider"`
ServerURL string `json:"server_url,omitempty"`
Repository string `json:"repository"`
EventName string `json:"event_name"`
EventRef string `json:"event_ref"`
PullRequest string `json:"pull_request,omitempty"`
BaseRef string `json:"base_ref,omitempty"`
HeadRef string `json:"head_ref,omitempty"`
HeadSHA string `json:"head_sha"`
Workflow string `json:"workflow,omitempty"`
Job string `json:"job,omitempty"`
Actor string `json:"actor,omitempty"`
RunID string `json:"run_id"`
RunNumber string `json:"run_number,omitempty"`
RunAttempt string `json:"run_attempt,omitempty"`
}
CIContext is provider-neutral CI attribution supplied by consumers. Audit preserves it but does not establish trust or validate identifiers.
type Coordinate ¶
type Coordinate struct {
DataSecurity string `json:"data_security,omitempty"`
BlastRadius string `json:"blast_radius,omitempty"`
NetworkEgress string `json:"network_egress,omitempty"`
FilesystemReach string `json:"filesystem_reach,omitempty"`
}
Coordinate mirrors umbra/profile.Coordinate as a JSON-stable snapshot. Duplicated here so audit.Record (the wire format) does not
type EgressRow ¶
type EgressRow struct {
Host string `json:"host"`
Decision string `json:"decision"`
BytesUp int64 `json:"bytes_up"`
BytesDown int64 `json:"bytes_down"`
DurationMS int64 `json:"duration_ms"`
}
EgressRow is one (parent-invocation, host) pair from the egress proxy. Decision is "allow" or "deny"; deny rows are produced when the host fails
func RedactEgressRows ¶
RedactEgressRows applies the data_security tier to egress rows. At "high" Host is stripped to the eTLD+1 best-effort (strip leading
type ProfileDecision ¶
type ProfileDecision struct {
Allowed bool `json:"allowed"`
Profile string `json:"profile,omitempty"`
Source string `json:"source,omitempty"`
Coordinate Coordinate `json:"coordinate"`
Reason string `json:"reason,omitempty"`
}
ProfileDecision is the structured outcome of a per-session lockdown-profile evaluation. Allowed=false plus a non-nil verb.Spec
type Record ¶
type Record struct {
// ID is a UUID v7 (time-ordered) populated on Append if unset. The
// stable identifier for an audit row, used to join cross-host records.
ID string `json:"id,omitempty"`
Timestamp int64 `json:"ts"`
Version string `json:"version,omitempty"`
Decision string `json:"decision"`
Verb string `json:"verb"`
Argv []string `json:"argv"`
ExitCode int `json:"exit_code"`
Error string `json:"error,omitempty"`
// StderrTail is a bounded last-N-bytes capture of the wrapped tool's
// stderr, populated by pass-through verbs on non-zero exit so the audit
StderrTail string `json:"stderr_tail,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
// RepoRoot is git rev-parse --show-toplevel of cwd at invocation time,
// or empty if cwd was not inside a git repo. Forensic only: tells the
RepoRoot string `json:"repo_root,omitempty"`
// CWDSubprocess is os.Getwd() captured by buildBaseRecord at the
// moment the subprocess saw the world. Differs from CWDAtInvocation
CWDSubprocess string `json:"cwd_subprocess,omitempty"`
// CWDAtInvocation is the consumer-resolved operator cwd, populated
// from verb.Spec.ResolveInvokeCWD when set. Empty when the consumer
CWDAtInvocation string `json:"cwd_at_invocation,omitempty"`
// SessionID joins this row to the Claude Code (or other agent harness)
// session that produced the invocation. Resolution order at write time:
SessionID string `json:"session_id,omitempty"`
// AuditOverride is set true when a repo verb ran with
// --audit-override-dirty: the clean+synced gate refused but the
AuditOverride bool `json:"audit_override,omitempty"`
// WorkingTreeStatus is the truncated `git status --porcelain` output
// captured when a repo verb ran with --audit-override-dirty. Empty for
WorkingTreeStatus string `json:"working_tree_status,omitempty"`
// CI carries consumer-validated continuous-integration attribution.
// The audit package preserves this evidence but does not establish trust.
CI *CIContext `json:"ci,omitempty"`
// Egress carries one row per host contacted by the wrapped subprocess
// when the verb runs through the per-invocation HTTP CONNECT proxy
Egress []EgressRow `json:"egress,omitempty"`
// ProfileDecision captures the per-session lockdown-profile evaluation
// for this verb, when a consumer has wired verb.Spec.OnEvaluate. Absent
ProfileDecision *ProfileDecision `json:"profile_decision,omitempty"`
// PolicySkipped is true when the shell-metacharacter validator was
// bypassed for this invocation. Set by consumers whose verb wiring
PolicySkipped bool `json:"policy_skipped,omitempty"`
// Cache is the TTL-cache disposition of a cache-eligible action, e.g. "hit"
// when a collect served from cache. Empty when no cache was consulted.
Cache string `json:"cache,omitempty"`
}
Record is one line in the audit log. Timestamp is unix seconds (int64), JSON-encoded as a number. Easier to sort and diff than RFC3339 strings.
func Filter ¶ added in v0.250.0
Filter returns the records keep accepts, in order. Rows from before Record.Version existed have an empty Version and match no version selector.
type RedactPolicy ¶
type RedactPolicy struct {
// SecretFlagPatterns is a list of flag-name prefixes (with leading
// dashes). Matching is "argv token starts with this prefix" so
SecretFlagPatterns []string
// IdentifierPatterns is a list of compiled regexes. Any match in
// Error/StderrTail/Reason fields gets replaced with [REDACTED].
IdentifierPatterns []*regexp.Regexp
}
RedactPolicy carries the patterns the consumer wants applied. umbra supplies the mechanism; the consumer supplies the patterns.
type Sink ¶
type Sink interface {
Emit(Span)
}
Sink receives every appended record, for a consumer exporting spans. It runs after the JSONL write, so the durable log never depends on it.
type Span ¶
type Span struct {
Name string
StartUnix int64
DurationMS int64
Error bool
Attributes map[string]any
}
Span is one audit record projected onto a tracing span: a name, whether it represents an error, and flat attributes. No SDK type appears here.
type Writer ¶
type Writer struct {
// Path is the JSONL file. Must be set.
Path string
// Now is used for timestamps. Tests override. Defaults to time.Now.
Now func() time.Time
// MaxSizeMB is the rotation trigger. Zero uses lumberjack's default (100).
MaxSizeMB int
// MaxBackups caps the number of rotated files retained. Zero keeps all.
MaxBackups int
// MaxAgeDays prunes rotated files older than this. Zero disables.
MaxAgeDays int
// Compress gzips rotated files.
Compress bool
// Version stamps rows whose Record.Version is unset, else BuildVersion.
// Generated binaries set it to their -ldflags version.
Version string
// Sinks receive each appended record as a Span, after the JSONL write.
// A panicking or slow sink is the consumer's problem, never the log's.
Sinks []Sink
// contains filtered or unexported fields
}
Writer appends records to a JSONL file. The zero value is unusable. Use NewWriter or set Path explicitly.
func NewWriter ¶
NewWriter returns a Writer with Now set to time.Now. Rotation fields default to zero (lumberjack defaults apply) and can be set by the caller.
Example ¶
The most basic shape: open a writer, append one record, close. The writer creates and rotates the JSONL file under the hood.
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/coilyco/umbra/pkg/audit"
)
func main() {
path := filepath.Join(os.TempDir(), "umbra-example.jsonl")
w := audit.NewWriter(path)
defer func() { _ = w.Close() }()
if err := w.Preflight(); err != nil {
fmt.Println("preflight:", err)
return
}
rec := audit.Record{Verb: "hello", Argv: []string{"hello", "world"}}
if err := w.Append(rec); err != nil {
fmt.Println("append:", err)
return
}
fmt.Println("ok")
}
Output: ok
func (*Writer) Append ¶
Append writes one record as a JSON line. Timestamp is populated from the Writer if unset on the Record (zero).
func (*Writer) Close ¶
Close releases the underlying log file. Safe to call multiple times and on a Writer that was never used. Call at process exit if you want to be
func (*Writer) Preflight ¶
Preflight ensures the audit directory exists with 0700 perms and that the target path is writable. Call at startup so a broken config blows up
func (*Writer) SetRedactPolicy ¶
func (w *Writer) SetRedactPolicy(p RedactPolicy)
SetRedactPolicy installs the consumer-supplied pattern list. Safe to call once at Runner construction. Calls during a hot pipeline
func (*Writer) Wrap ¶
Wrap records an invocation by running fn and logging the result. base supplies caller-set fields (Verb, Argv, RepoRoot, Version);
Example ¶
Wrap runs a function and records one audit row per invocation, regardless of whether the function returns an error.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/coilyco/umbra/pkg/audit"
)
func main() {
w := audit.NewWriter(filepath.Join(os.TempDir(), "umbra-wrap-example.jsonl"))
defer func() { _ = w.Close() }()
_ = w.Preflight()
err := w.Wrap(context.Background(), audit.Record{Verb: "demo"}, func() error {
fmt.Println("doing work")
return nil
})
fmt.Println("err:", err)
}
Output: doing work err: <nil>