Documentation
¶
Overview ¶
Package audit — redact.go implements sensitive field redaction for audit records.
Redaction is applied before every audit record is written to disk (in writeAuditWithAC in internal/check/handler.go). The default patterns cover the three most common credential leak vectors observed in agent tool outputs (T-04-05-02):
- Authorization: Bearer <token> — HTTP auth headers
- eyJ... — JWT tokens embedded in any string
- sk-proj-/sk-ant-/AKIA/ghp_/glpat- prefixes — common API key namespaces
All patterns are non-backtracking character classes (no nested quantifiers) to prevent catastrophic backtracking (T-04-05-07). Each pattern is pre-compiled once via defaultRedactPatterns().
applyRedaction is a pure function — it returns a new string, never modifies the input. RedactRecord returns a copy of the AuditRecord with sensitive fields replaced.
Package audit provides the Phase 1 NDJSON audit log: a Bumblebee-compatible append-only writer that records one record per policy decision with owner-only file permissions.
Every decision the hook handler makes — including fail-closed decisions — must be written here. The schema is Bumblebee-compatible (AUDT-01 minimum, CTLG-07 signedness provenance); full audit sinks (syslog, OTLP, query, export, rotation) are Phase 6 and deliberately out of scope.
Index ¶
- Variables
- func DefaultRedactPatterns() []redactPattern
- func Export(ctx context.Context, r io.Reader, opts ExportOpts, out io.Writer) error
- func HasSensitiveData(s string) bool
- func Query(ctx context.Context, r io.Reader, opts QueryOpts, out io.Writer) error
- func RedactPatternsWith(custom []string) ([]redactPattern, error)
- func RedactString(s string) string
- func RedactStringSlice(ss []string, patterns []redactPattern) []string
- func Rotate(auditPath string, maxBytes int64, retentionDays int) error
- func ValidateRemoteSinkEndpoint(endpoint string, requireHTTPS bool) error
- type AuditRecord
- type CatalogProvenance
- type ExportOpts
- type HTTPSink
- type MultiSink
- type OTLPSink
- type QueryOpts
- type Sink
- type SyslogSink
- type Writer
- type WriterSink
Constants ¶
This section is empty.
Variables ¶
var ErrSyslogNotSupported error // nil on linux/darwin
ErrSyslogNotSupported is returned on platforms that do not support syslog. It is defined here (linux/darwin build) as a nil sentinel; the windows stub defines the real error value. Both files share the same exported symbol name so callers can use errors.Is regardless of OS.
Note: the actual non-nil ErrSyslogNotSupported lives in syslog_stub.go (windows build). On linux/darwin we do not need it, but callers in sink.go reference it unconditionally, so we provide a nil var here for completeness. The errors.Is(err, ErrSyslogNotSupported) check in sink.go is safe: when NewSyslogSink succeeds on linux/darwin the var is nil and the check is never reached on the success path.
Functions ¶
func DefaultRedactPatterns ¶
func DefaultRedactPatterns() []redactPattern
DefaultRedactPatterns returns the default set of sensitive-field redaction patterns. Each pattern uses non-backtracking character classes (no nested quantifiers) to prevent catastrophic backtracking (T-04-05-07).
Patterns:
- Bearer tokens: Authorization: Bearer <token> → Authorization: Bearer [REDACTED]
- JWT tokens: eyJ<header>.<payload>.<sig> → [JWT_REDACTED]
- Common API key prefixes: sk-proj/sk-ant/AKIA/ghp_/glpat- → prefix[REDACTED]
WR-05: patterns are compiled once via sync.Once and reused on subsequent calls. This eliminates per-call regexp compilation overhead in the audit hot path (check handler + gateway handler both call this per request).
This function is exported so callers (check.writeAuditWithAC, gateway.writeAudit) can apply redaction at the single chokepoint before writing to disk.
func Export ¶
Export reads NDJSON records from r, applies the filters embedded in opts, and writes matching records to out in the requested format. Supported formats:
- "ndjson" — raw NDJSON lines (delegates to Query)
- "csv" — RFC 4180 CSV with a fixed header row
- "otlp" — OpenTelemetry Logs OTLP/JSON envelope
Unknown formats return an error immediately without reading from r.
func HasSensitiveData ¶
HasSensitiveData reports whether s matches any default redaction pattern. It is used in tests to verify that redaction is applied correctly.
func Query ¶
Query streams NDJSON lines from r, applies the filters in opts, and writes matching raw lines to out. It never re-marshals records — the raw line bytes are forwarded verbatim so downstream tooling can re-parse without loss.
Malformed lines are silently skipped; a summary count is printed after the loop when any lines were skipped. Query returns nil unless a context cancellation or write error occurs.
func RedactPatternsWith ¶
RedactPatternsWith compiles the caller-supplied custom regex pattern strings and APPENDS them to DefaultRedactPatterns(), returning the combined set.
This is the chokepoint that makes config.GetRedactPatterns() (the `redact_patterns` config key) actually take effect. Without it, custom patterns were plumbed through config but silently inert — every redaction site used the hardcoded defaults (the LOW-severity "silently inert" finding). Callers that have config-supplied patterns should compile them through this helper and pass the result to RedactRecord:
patterns, err := audit.RedactPatternsWith(cfg.GetRedactPatterns())
if err != nil { /* surface the config error; fall back to defaults */ }
rec = audit.RedactRecord(rec, patterns)
Security invariant: custom patterns are ALWAYS appended to — never replace — the defaults. A defender (or an attacker who can influence config) therefore cannot weaken the built-in Bearer/JWT/API-key redaction by supplying a narrower or empty pattern list; they can only add additional redaction.
Each custom pattern is anchored with a custom replacement of "[REDACTED]". A pattern that fails to compile aborts with a wrapped error (the first bad pattern is reported); the caller decides whether to fall back to the defaults. The returned defaults slice is the shared sync.Once-compiled value; the combined slice is freshly allocated and safe for the caller to retain.
When custom is empty, RedactPatternsWith returns DefaultRedactPatterns() verbatim (no allocation) with a nil error.
func RedactString ¶
RedactString is a convenience helper for single-string redaction using the default patterns. It is used in tests and for ad-hoc redaction outside of audit records.
func RedactStringSlice ¶
RedactStringSlice applies redaction to each element of ss and returns a new slice. Elements that do not match any pattern are returned unchanged.
func Rotate ¶
Rotate rotates the audit log at auditPath when its size exceeds maxBytes. Archives are named beekeeper.ndjson.1, beekeeper.ndjson.2, … up to .999. Archives older than retentionDays are deleted before the shift.
Rotate is NOT concurrent-safe on its own — the Writer mutex (added in Plan 03) serialises it. Rotate itself has no mutex.
func ValidateRemoteSinkEndpoint ¶
Finding #12 (MEDIUM): the remote audit sink constructors POST the full record JSON to any URL with no validation. A misconfigured (or attacker-influenced) endpoint can turn the audit pipeline into an SSRF / credential-exfil channel — e.g. an http:// endpoint sends records in cleartext, and a link-local target like http://169.254.169.254/ reaches a cloud instance-metadata service.
ValidateRemoteSinkEndpoint fails CLOSED at construction so a misconfigured sink is rejected before any record leaves the host, rather than silently exfiltrating. It is a pure string check — it performs NO DNS resolution at construction (a hostname that only resolves to a private range at runtime is out of scope by design; we reject obvious SSRF targets by literal host).
Rejected:
- non-https:// schemes (the remote sinks must use TLS in transit)
- empty / whitespace-only hosts
- loopback literals: localhost, 127.0.0.0/8, ::1
- link-local literals: 169.254.0.0/16 (esp. the 169.254.169.254 metadata IP)
- RFC 1918 private literals: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
requireHTTPS lets a future caller relax the TLS requirement; the OTLP and HTTPS audit sinks always pass true.
Types ¶
type AuditRecord ¶
type AuditRecord struct {
RecordType string `json:"record_type"` // "policy_decision"
RecordID string `json:"record_id"`
Timestamp string `json:"timestamp"` // RFC3339
ScannerName string `json:"scanner_name"` // always "beekeeper"
AgentName string `json:"agent_name"`
ToolName string `json:"tool_name"`
Decision string `json:"decision"` // allow|warn|block|alert (alert = Sentry detection-only, set by the corpus emitter in Phase 23)
Reason string `json:"reason"`
RuleIDs []string `json:"rule_ids"`
CatalogMatches []CatalogProvenance `json:"catalog_matches"`
Endpoint string `json:"endpoint"` // "check" in Phase 1
// Phase 2 additions (CTLG-09):
CorroborationCount int `json:"corroboration_count"`
SourcesAgreed []string `json:"sources_agreed"`
SourcesDissented []string `json:"sources_dissented"`
Quarantine bool `json:"quarantine,omitempty"`
// Phase 4 additions (INTG-07): multi-agent lineage for forensic audit trail.
AgentID string `json:"agent_id,omitempty"`
ParentAgentID string `json:"parent_agent_id,omitempty"`
AgentDepth int `json:"agent_depth,omitempty"`
AgentLineage []string `json:"agent_lineage,omitempty"`
// Phase 5 additions: sentry_alert record type (SLNX-08)
SentryRuleID string `json:"sentry_rule_id,omitempty"`
SentryRuleName string `json:"sentry_rule_name,omitempty"`
SentrySeverity string `json:"sentry_severity,omitempty"`
SentryBaselineMode bool `json:"sentry_baseline_mode,omitempty"`
SentryProcessPID uint32 `json:"sentry_process_pid,omitempty"`
SentryProcessExe string `json:"sentry_process_exe,omitempty"`
SentryParentChain []string `json:"sentry_parent_chain,omitempty"`
SentryFilesAccessed []string `json:"sentry_files_accessed,omitempty"`
SentryNetworkDests []string `json:"sentry_network_dests,omitempty"`
SentryQuarantineRec bool `json:"sentry_quarantine_recommended,omitempty"`
// Phase 6 additions (LLMF-02, LLMF-03, LLMF-04)
LLMFScanned bool `json:"llmf_scanned,omitempty"`
LLMFScanKind string `json:"llmf_scan_kind,omitempty"` // prompt|code|alignment
LLMFResult string `json:"llmf_result,omitempty"` // clean|injection|unsafe|hijacked
LLMFConfidence float64 `json:"llmf_confidence,omitempty"`
LLMFLatencyMS int64 `json:"llmf_latency_ms,omitempty"`
// OriginalCommand / ReasonCode are general-purpose provenance fields. They
// were introduced for the package-manager nudge (removed in v1.1.0) but are
// ALSO used by the config_change record path (the dashboard settings panel and
// `beekeeper config set` record the changed key + a human-readable summary
// here), so they remain live — do NOT treat them as nudge-only.
OriginalCommand string `json:"original_command,omitempty"`
// Deprecated: nudge removed in v1.1.0; field retained for corpus schema compatibility, no longer populated.
RewrittenCommand string `json:"rewritten_command,omitempty"`
// ReasonCode carries a structured reason/key code. Still populated by the
// config_change path (the changed config key).
ReasonCode string `json:"reason_code,omitempty"`
// Deprecated: nudge removed in v1.1.0; field retained for corpus schema compatibility, no longer populated.
PMState string `json:"pm_state,omitempty"`
// Deprecated: nudge removed in v1.1.0; field retained for corpus schema compatibility, no longer populated.
NudgeAction string `json:"nudge_action,omitempty"`
// SourceSurface is the branch key identifying which Beekeeper surface produced
// this record. Valid values: hook|mcp_gateway|shim|file_watcher|sentry|scan.
// Populated by the corpus emitter (Phase 23) from context, or by the Sentry
// surface directly. (SCHEMA-01)
SourceSurface string `json:"source_surface,omitempty"`
// ClusterID binds correlated non-agent events (e.g. a Sentry correlation
// window). Agent-mediated surfaces (hook/mcp_gateway/shim) adjudicate per
// event; non-agent surfaces (file_watcher/sentry/scan) adjudicate per cluster.
// Sentry surface may set this directly; the corpus emitter reads it. (SCHEMA-02)
ClusterID string `json:"cluster_id,omitempty"`
// RulesetVersion is the catalog snapshot version at decision time. Populated
// by the policy loader. Recorded so schema/rule evolution is detectable. (SCHEMA-05)
RulesetVersion string `json:"ruleset_version,omitempty"`
// PostureOverrideAction names the graduated override the operator chose:
// "allow_once" | "allow_always" | "enforce_block" | "enforce_warn". This is the
// distinct-record discriminator the verifier and the audit viewer key on.
PostureOverrideAction string `json:"posture_override_action,omitempty"`
// PostureRule is the posture rule the override scopes to: "" (all rules, for an
// all-rules allow), "release-age", "lifecycle", or "git-remote".
PostureRule string `json:"posture_rule,omitempty"`
// PostureEcosystem is the optional ecosystem the allow override is scoped to.
PostureEcosystem string `json:"posture_ecosystem,omitempty"`
// PosturePackage is the package an allow override exempts (empty for an enforce
// override, which scopes to a rule, not a package).
PosturePackage string `json:"posture_package,omitempty"`
}
AuditRecord is one NDJSON line in the audit log: a single policy decision with its provenance. ScannerName is always the literal "beekeeper" and Endpoint is "check" in Phase 1 (the only decision surface). RecordID and Timestamp are caller-supplied so that FromDecision stays a pure mapping and is trivially testable; the hook handler supplies real values at runtime.
Phase 2 additions (CTLG-09): CorroborationCount, SourcesAgreed, SourcesDissented, and Quarantine carry the full corroboration provenance so operators know exactly which sources agreed/dissented on every decision.
func FromDecision ¶
func FromDecision(tc policy.ToolCall, d policy.Decision, recordID, timestamp string, ac policy.AgentContext) AuditRecord
FromDecision maps a policy Decision plus the originating tool call and caller-supplied metadata into an AuditRecord. It performs no I/O and reads no wall clock — recordID and timestamp are passed in verbatim — so it remains a pure, side-effect-free mapping that the hook handler and tests both rely on.
Phase 2 (CTLG-09): the catalog_matches slice is always present (non-nil), even when empty, so non-catalog decisions serialize as `"catalog_matches":[]` rather than `"catalog_matches":null`. The new corroboration fields are mapped directly from the Decision.
Phase 4 (INTG-07): ac carries agent lineage fields. Zero-value AgentContext{} produces no agent fields in the JSON output (all fields are omitempty).
func RedactRecord ¶
func RedactRecord(rec AuditRecord, patterns []redactPattern) AuditRecord
RedactRecord returns a copy of rec with sensitive string values replaced by redaction placeholders. The following fields are redacted:
- Reason: may contain credential snippets from policy engine messages
- OriginalCommand: the verbatim agent-supplied Bash command (Phase 8 nudge, WR-01) — may carry a token/secret embedded in an install argument such as `npm install --registry=https://x:Bearer ...@host/`.
- RewrittenCommand: the nudge-rewritten command (Phase 8) — derived from the original and may inherit the same secrets.
- PMState: the flattened §9 PM-state string (Phase 8). It is structured detection metadata today, but it is redacted defensively so that no attacker-influenced data path bypasses redaction (WR-01).
- SentryProcessExe: the path of the monitored process. May embed credential paths or tokens in argv-derived exe strings on some platforms (TM-D-03).
- SentryCorrelatedExt: the extension ID correlated with a sentry alert. May contain publisher-supplied strings that embed tokens (TM-D-03).
- SentryFilesAccessed: slice of file paths observed by the Sentry — may contain credential paths or secrets embedded in paths (TM-D-03).
- SentryNetworkDests: slice of network destinations — may contain bearer tokens embedded in URL query strings or authority components (TM-D-03).
- CatalogProvenance: struct slice — Reason and EntryID fields within each match may carry attacker-controlled strings from the catalog (TM-D-03).
Non-sensitive structural fields (RecordType, Decision, Timestamp, RuleIDs, boolean flags, numeric counters) are NOT redacted.
ToolInput is a map[string]any in policy.ToolCall (not in AuditRecord directly); string values would be redacted at the ToolCall layer if exposed here.
RedactRecord always returns a new AuditRecord — it never mutates the receiver.
func RedactRecordWithDefaults ¶
func RedactRecordWithDefaults(rec AuditRecord) AuditRecord
RedactRecordWithDefaults returns a copy of rec with all default sensitive patterns applied. This is the corpus store's cross-package redaction entrypoint (Phase 23 prerequisite — research Finding 7 / T-22-02).
The unexported redactPattern type used by RedactRecord and DefaultRedactPatterns cannot cross package boundaries, so internal/corpus cannot call RedactRecord(rec, DefaultRedactPatterns()) directly. This wrapper exposes a cross-package-safe signature that takes only AuditRecord and returns AuditRecord, with no unexported types in the signature.
RedactRecordWithDefaults never mutates the input rec — it delegates to RedactRecord which always returns a new AuditRecord (copy semantics).
type CatalogProvenance ¶
type CatalogProvenance struct {
CatalogSource string `json:"catalog_source"`
EntryID string `json:"entry_id"`
Ecosystem string `json:"ecosystem"`
Package string `json:"package"`
Version string `json:"version"`
Severity string `json:"severity"`
Signed bool `json:"signed"`
// Phase 2 additions (CTLG-09):
Corroborated bool `json:"corroborated"`
Dissented bool `json:"dissented"`
CatalogVersion string `json:"catalog_version"`
}
CatalogProvenance is the audit-record view of a single catalog hit. It mirrors policy.CatalogMatch field-for-field, including the Signed flag (CTLG-07), so the audit log records exactly which catalog source, entry, and signedness drove a decision.
Phase 2 additions (CTLG-09): Corroborated, Dissented, and CatalogVersion carry per-match provenance so each source's role in the corroboration decision is recorded in the forensic trail.
type ExportOpts ¶
ExportOpts controls format and record filtering for Export.
type HTTPSink ¶
type HTTPSink struct {
// contains filtered or unexported fields
}
HTTPSink delivers each AuditRecord as an individual HTTPS POST to the configured endpoint. The body is a single NDJSON line. Errors are treated as fire-and-forget: they are logged to stderr but never returned to the caller so a remote endpoint outage does not affect the local file audit trail.
func NewHTTPSink ¶
NewHTTPSink returns an HTTPSink that POSTs each record to endpoint.
func (*HTTPSink) Write ¶
func (s *HTTPSink) Write(rec AuditRecord) error
Write serialises rec as NDJSON and POSTs it to the endpoint.
type MultiSink ¶
type MultiSink struct {
// contains filtered or unexported fields
}
MultiSink fans out every Write and Close call to all registered sinks. Errors from individual sinks are not short-circuited: every sink always receives the call. The last non-nil error is returned.
func NewMultiSinkFromSinks ¶
NewMultiSinkFromSinks returns a MultiSink that owns sinks.
func (*MultiSink) Write ¶
func (m *MultiSink) Write(rec AuditRecord) error
Write delivers rec to every sink; returns the last non-nil error.
type OTLPSink ¶
type OTLPSink struct {
// contains filtered or unexported fields
}
OTLPSink batches AuditRecords and flushes them as OTLP LogsData JSON to the configured endpoint via HTTP POST. Batches are flushed when they reach 100 records or when Close is called. Flush errors are logged to stderr and treated as fire-and-forget — they are never returned to the caller — so a remote collector outage does not affect the local file audit trail.
func NewOTLPSink ¶
NewOTLPSink returns an OTLPSink that POSTs to endpoint.
func (*OTLPSink) Write ¶
func (s *OTLPSink) Write(rec AuditRecord) error
Write appends rec to the current batch. If the batch reaches 100 records it is flushed immediately (fire-and-forget on flush error).
type QueryOpts ¶
type QueryOpts struct {
Since time.Time // zero value = no lower bound
Agent string // empty = no filter
Tool string // empty = no filter
Decision string // empty = no filter (allow|warn|block)
Limit int // 0 = no limit
}
QueryOpts controls the streaming filter applied by Query.
type Sink ¶
type Sink interface {
Write(rec AuditRecord) error
Close() error
}
Sink is the common interface for audit output targets. Every implementation must be safe for concurrent use (the Writer mutex guards calls from the file sink; remote sinks must manage their own concurrency).
func NewMultiSink ¶
func NewMultiSink(auditPath string, cfg config.AuditConfig) (Sink, error)
NewMultiSink constructs the full sink graph for auditPath and the provided config.AuditConfig. A file-backed WriterSink is always created as the first sink. Additional sinks (syslog, otlp, https) are appended based on cfg.Sinks.
If NewSyslogSink returns ErrSyslogNotSupported the syslog sink is skipped with a warning printed to stderr; any other syslog error is returned to the caller. Remote sinks (OTLP, HTTPS) print a machine-readable warning so operators know audit data is leaving the local host.
func NewMultiSinkWithCorpus ¶
func NewMultiSinkWithCorpus(auditPath string, auditCfg config.AuditConfig, corpusSink Sink) (Sink, error)
NewMultiSinkWithCorpus is identical to NewMultiSink but appends an additional caller-supplied corpus sink to the fan-out graph.
Design (OQ-1 resolution, Option C — least invasive):
- NewMultiSink is UNCHANGED. Existing call sites are unaffected.
- The corpus sink is passed as an audit.Sink INTERFACE (not as a concrete *corpus.StoreSink) to avoid an audit→corpus import cycle. The caller (cmd/beekeeper) constructs the corpus.StoreSink and passes it in as audit.Sink.
- Fan-out semantics match MultiSink: every Write delivers to all sinks; errors from individual sinks are accumulated and the last non-nil error is returned. A corpus sink error does NOT prevent the file sink from receiving the record.
corpusSink may be nil (e.g. when cfg.Corpus.Enabled is false); in that case NewMultiSinkWithCorpus behaves identically to NewMultiSink.
type SyslogSink ¶
type SyslogSink struct {
// contains filtered or unexported fields
}
SyslogSink delivers AuditRecords to a remote or local syslog daemon using RFC 5424 framing. The priority is LOG_LOCAL0|LOG_INFO (facility 16, severity 6).
func NewSyslogSink ¶
func NewSyslogSink(address string) (*SyslogSink, error)
NewSyslogSink dials the syslog daemon at address. Address format: "udp:host:port", "tcp:host:port", or "host:port" (UDP default).
func (*SyslogSink) Close ¶
func (s *SyslogSink) Close() error
Close closes the underlying syslog connection.
func (*SyslogSink) Write ¶
func (s *SyslogSink) Write(rec AuditRecord) error
Write sends rec as an RFC 5424 syslog message. The MSG part is the full JSON representation of the record so no information is lost.
type Writer ¶
type Writer struct {
// contains filtered or unexported fields
}
Writer is an append-only NDJSON sink for AuditRecords. It enforces owner-only file permissions (0600 on Unix, owner-only DACL on Windows) on open and re-applies them after every write so that a recreated or externally-modified file is never left world-readable (Pitfall 5).
Phase 6 additions (AUDT-03/04): Writer now carries a sync.Mutex (safe for concurrent hook-handler calls), an optional maxBytes rotation threshold, and a list of additional remote sinks (syslog, OTLP, HTTPS) that receive a fan-out copy of each record after the local file write succeeds.
func NewWriter ¶
NewWriter opens (creating if needed) the audit log at path for appending and enforces owner-only permissions immediately. The parent directory is created with 0700 if absent. The file is opened with O_APPEND|O_CREATE|O_WRONLY: O_APPEND avoids truncating or recreating an existing log (Pitfall 5), so prior decision records are never lost.
NewWriter is equivalent to NewWriterWithOptions(path, 0, nil).
func NewWriterWithOptions ¶
NewWriterWithOptions is like NewWriter but also configures log rotation and additional remote sinks. maxBytes controls when Rotate is called (0 = never). sinks receives a fan-out copy of each record after the file write; errors from remote sinks are logged to stderr and do not affect the caller's error return.
func (*Writer) Write ¶
func (w *Writer) Write(rec AuditRecord) error
Write marshals rec to a single NDJSON line and appends it to the audit log, then re-applies owner-only permissions. Permissions are re-enforced on every write so that if the file was recreated or its DACL reset between writes it is re-locked before the next record is observed (Pitfall 5).
Phase 6: Write acquires the Writer mutex before any I/O, making it safe for concurrent callers. After the file write, if maxBytes > 0 a rotation check is performed (errors are logged to stderr but do not fail the write). Finally, the record is fanned out to any additional sinks (remote sinks log errors to stderr internally and never surface them here).
Write returns any file-write or permissions error verbatim and never downgrades a decision on failure — the hook handler owns the fail-closed semantics for audit-write errors.
type WriterSink ¶
type WriterSink struct {
// contains filtered or unexported fields
}
WriterSink wraps an existing *Writer so it satisfies the Sink interface.
func NewWriterSink ¶
func NewWriterSink(w *Writer) *WriterSink
NewWriterSink returns a Sink that delegates to w.
func (*WriterSink) Write ¶
func (s *WriterSink) Write(rec AuditRecord) error
Write delegates to the underlying Writer.