secrets

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Jun 26, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package secrets implements the encrypted-at-rest secret store described in .agents/SECRETS.md.

Storage: one YAML file (`secrets.yaml`) containing a version tag and a map of name → entry. Each entry's value is serialised as

AES256GCM:v1:<base64 nonce>:<base64 ciphertext>

Key derivation: PBKDF2(passphrase, salt="baifo-secrets-v1", iter=200000, len=32). The passphrase comes from `baifo.encryption_key` (see internal/config).

When `baifo.encryption_key` is empty the store runs in plaintext mode: the file is still well-formed YAML but values are serialised verbatim with the `plain:v1:<base64>` prefix. This mode is meant for local development; the `encrypted: false` flag at the top of the file makes the mode explicit so accidents (key configured later) surface as a loud error instead of "can't decrypt".

Moving between modes is explicit and supported via Encode / Decode (exposed in the TUI as `/secret encode` and `/secret decode`).

Index

Constants

This section is empty.

Variables

View Source
var ErrNotFound = errors.New("secret not found")

ErrNotFound is returned by Get and Delete when the requested secret does not exist.

Functions

func Expand

func Expand(ctx context.Context, store *Store, allower Allower, args any) (any, context.Context, error)

Expand walks an arbitrary tool-call argument value and substitutes every ${secret:NAME} placeholder with the real secret value, provided (a) the agent's allowlist permits NAME and (b) the secret exists.

Placeholders for unknown or disallowed names are left in place so the downstream tool surfaces a clear "Authorization header malformed: literal ${secret:foo}" type of error, which the LLM can recover from.

The function also returns a context derived from ctx that stores the (name → raw value) pairs actually substituted. Pass that derived context to Redact after the tool returns.

func ExpandedPairs

func ExpandedPairs(ctx context.Context) map[string]string

ExpandedPairs returns the (name → raw value) pairs that Expand substituted during this call. Callers that need to scrub a result outside the standard Redact pipeline can use this to read the pairs directly. Returns an empty map when nothing was expanded.

func Redact

func Redact(ctx context.Context, result any) (any, error)

Redact walks an arbitrary tool-call result and replaces every raw secret value that was substituted in this call (looked up via ctx) with its ${secret:NAME} placeholder. Secrets that were not actually expanded in this call are NOT scanned: that would be expensive and false-positive prone.

Types

type AllowAll

type AllowAll struct{}

AllowAll permits every secret. Reserved for the root agent, which is the user-facing coordinator and the frontier of trust: it must be able to dereference any secret the operator stored. Sub-agents (static or dynamic) never receive this Allower — they go through AllowerFor with their explicit allowlist.

func (AllowAll) Allowed

func (AllowAll) Allowed(string) bool

Allowed always returns true.

type AllowList

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

AllowList implements Allower with a whitelist. Names not in the list are rejected. Used when an agent's allowed_secrets is non-empty.

func NewAllowList

func NewAllowList(names []string) AllowList

NewAllowList returns an Allower that permits exactly the given names.

func (AllowList) Allowed

func (a AllowList) Allowed(name string) bool

Allowed reports whether name is in the whitelist.

type AllowNone

type AllowNone struct{}

AllowNone forbids every secret. This is the least-privilege default for any agent built from an explicit (possibly empty) allowlist: static templates with no allowed_secrets, dynamic workers whose spawn call omitted the field, etc. The model still sees the placeholder name in tool args; the expander leaves it untouched.

func (AllowNone) Allowed

func (AllowNone) Allowed(string) bool

Allowed always returns false.

type Allower

type Allower interface {
	Allowed(name string) bool
}

Allower decides whether a given agent is allowed to see the named secret. See AllowerFor for how an allowlist slice maps to the concrete implementations: AllowAll, AllowNone, AllowList.

func AllowerFor

func AllowerFor(allowed []string) Allower

AllowerFor returns an Allower for an explicit allowlist.

  • len(allowed) == 0 → AllowNone (least-privilege default for any agent built from an explicit list, regardless of whether the slice is nil or just empty).
  • non-empty list → AllowList of those names.

The root agent does NOT go through this function — it is wired directly to AllowAll in the builder. See agent.Spec.UnrestrictedSecrets for the toggle.

type Entry

type Entry struct {
	Name        string
	Description string
	CreatedAt   time.Time
	RotatedAt   time.Time
}

Entry is the public representation of a secret as listed by the CLI. The raw value is never embedded here.

type LogRedactor

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

LogRedactor replaces every stored secret value that appears in a log attribute with its ${secret:NAME} placeholder. It implements the logging.Redactor interface (structural, no import of logging to avoid an import cycle) and is safe for concurrent use.

The secret snapshot is cached between log lines and only refreshed when the Store's generation counter changes, so the expensive decrypt-all path (PBKDF2 + AES-GCM per entry) is amortised across as many log lines as happen between mutations. A nil Store or enabled=false makes every Redact call a cheap no-op.

func NewLogRedactor

func NewLogRedactor(store *Store, enabled bool, minLen int) *LogRedactor

NewLogRedactor creates a LogRedactor for the given Store. Pass a nil store when the store is not yet available at logging init time; call SetStore once the store is ready. enabled gates all redaction; minLen is the minimum byte length a secret value must have to be considered (mirrors SecretsConfig.EffectiveMinScrubLength).

func (*LogRedactor) Redact

func (r *LogRedactor) Redact(attr slog.Attr) slog.Attr

Redact implements logging.Redactor. It walks the attribute value, replacing any occurrence of a cached secret value with its ${secret:NAME} placeholder. Group attributes are recursed into so structured log lines are covered as well. Non-string typed values are rendered to a string only when a secret is actually found in them, preserving the original type otherwise.

func (*LogRedactor) SetConfig

func (r *LogRedactor) SetConfig(enabled bool, minLen int)

SetConfig updates the enabled flag and minLen floor in place and invalidates the cached snapshot. Both changes take effect on the next Redact call.

func (*LogRedactor) SetStore

func (r *LogRedactor) SetStore(store *Store)

SetStore replaces the underlying secrets Store and invalidates the cached snapshot. Safe to call concurrently; designed for use by ReloadFromDisk to point the live redactor at a freshly opened store without reconstructing the handler chain.

type Store

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

Store is the entry point. When aead is nil the store runs in plaintext mode; otherwise every value is AES-256-GCM sealed before hitting disk.

func NewStore

func NewStore(dir, passphrase string) (*Store, error)

NewStore opens (or initialises) the secret store living in <dir>. The passphrase is the value of baifo.encryption_key from baifo.yaml. An empty passphrase puts the store in plaintext mode (intended for local development); a non-empty passphrase enables AES-256-GCM.

If the file already exists, its `encrypted` flag must match the configured mode — otherwise NewStore returns a clear error explaining how to reconcile (use `/secret encode` or `/secret decode`). This guards against the silent footgun of "key was unset, secrets now unreadable" or "key was set, secrets still plaintext on disk".

func (*Store) Decode

func (s *Store) Decode() (int, error)

Decode unwraps every encrypted entry into the plaintext format. Requires the store to currently hold the AEAD (so it can decrypt), but flips the on-disk flag to false so future Sets stay plaintext and `baifo.encryption_key` can be removed from the config.

IMPORTANT: this is intentionally destructive of confidentiality. Callers (the TUI) MUST present a confirmation prompt before invoking. Returns the number of entries converted.

func (*Store) Delete

func (s *Store) Delete(name string) error

Delete removes a secret. Returns ErrNotFound if it was not stored.

func (*Store) Encode

func (s *Store) Encode() (int, error)

Encode re-seals every plaintext entry into the encrypted format. Requires the store to be in encrypted mode (i.e. encryption_key is configured). It is a no-op when there is nothing to convert, so it is safe to call repeatedly. Returns the number of entries converted.

func (*Store) Encrypted

func (s *Store) Encrypted() bool

Encrypted reports whether the store will write new values encrypted. Mirrors the on-disk flag so callers can render a badge.

func (*Store) Generation

func (s *Store) Generation() uint64

Generation returns the current mutation counter. It increments atomically on every successful write (Set, Delete, Encode, Decode) so external caches can detect stale snapshots without decrypting every entry on each check. The value starts at zero and wraps on overflow (2^64 iterations of baifo are not expected in a session).

func (*Store) Get

func (s *Store) Get(name string) (string, error)

Get decrypts and returns the value of the secret named name. Returns ErrNotFound when the name is unknown.

func (*Store) List

func (s *Store) List() ([]Entry, error)

List returns the public metadata of every stored secret, sorted by name. Values are never returned by this method.

func (*Store) Set

func (s *Store) Set(name, value, description string) error

Set encrypts and writes (or replaces) the secret named name. If the secret already exists, its CreatedAt is preserved and only RotatedAt is bumped.

func (*Store) Snapshot

func (s *Store) Snapshot() (map[string]string, error)

Snapshot returns every secret's name → plaintext value in one shot. Unlike List, this method DOES decrypt every entry, so the returned map carries raw values. Callers that handle the result MUST keep it in memory only, never log it, and let the map go out of scope as soon as possible.

The intended consumer is the AfterToolCallback redactor's comprehensive-scrub pass (see SECRETS.md), which needs to scan a tool result for every stored value at once instead of just the values the BeforeToolCallback substituted. Decrypting once per tool call (vs. N times via Get) avoids thrashing AES-GCM.

An entry whose ciphertext is malformed is silently skipped — the redactor has no recourse to recover from a bad seal and we prefer degraded redaction over a hard failure that would block every tool call. The CLI's `baifo secrets list` will still surface the broken entry separately.

Jump to

Keyboard shortcuts

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