secrets

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 6, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package secrets resolves ${secret:foo.bar} and ${env:FOO} references at apply-time. The active backend is selected from agentsync.toml secrets `backend` field (env|age).

Index

Constants

View Source
const DefaultAgeFile = "secrets/secrets.age"

DefaultAgeFile is the encrypted-secrets location relative to the agentsync home, used when secrets.file is unset (it is optional in the init template). apply, verify, diff, doctor, and the `secrets` subcommands MUST resolve the age file the same way — otherwise a user who omits `file` can `secrets set` / `secrets get` successfully (those default the path) yet have apply fail to find the same file.

View Source
const SkipPermCheckEnv = "AGENTSYNC_AGE_SKIP_PERM_CHECK"

SkipPermCheckEnv is the env var that disables CheckIdentityPermissions. Set to "1" for setups (network homes, dev containers) where mode bits don't reflect actual access (e.g. NFS roots squash, ACLs override).

Variables

This section is empty.

Functions

func CheckIdentityPermissions

func CheckIdentityPermissions(path string) error

CheckIdentityPermissions ensures the age identity file is not readable by group or other. A 0o644 identity file defeats the purpose of encryption. On Windows the mode bits are not POSIX semantics; we skip the check there. Users can opt out by setting AGENTSYNC_AGE_SKIP_PERM_CHECK=1.

func CollectResolved

func CollectResolved(c *source.Canonical, sec, env Resolver) map[string]string

CollectResolved walks every string field of the canonical that SubstituteCanonical would expand, resolves each ${secret:…} / ${env:…} reference, and returns a map from resolved-value → original placeholder.

Used by callers that need to print text which may contain resolved secret values (notably `agentsync diff`, which reads the on-disk file — written by a prior apply with the secret already substituted — and would otherwise leak the cleartext token to stdout). Unresolvable references are silently skipped; this is a redaction helper, not a validator.

func Decrypt

func Decrypt(ageFile, identityFile string) ([]byte, error)

Decrypt decrypts an age-encrypted file using the identity in identityFile and returns the plaintext bytes.

func Encrypt

func Encrypt(plaintext []byte, recipient string, dest string) error

Encrypt writes plaintext as-is, encrypted to the given age X25519 recipient public key, into dest. The encrypted bytes are produced in memory and committed via the iox atomic-write path: write to a sibling tmp file (0o600), fsync, rename onto dest. This is critical for secrets.age — a previous implementation opened dest with O_TRUNC and streamed encrypted bytes into it; a Ctrl-C, panic, or disk-full between truncate and close left a zero-byte secrets.age with NO backup, losing every key the user had ever stored.

plaintext is typically TOML-formatted secrets.

func MaskResolved

func MaskResolved(s string, resolved map[string]string) string

MaskResolved replaces every resolved value in b with its original placeholder. Idempotent — a placeholder that appears in b stays as a placeholder. Designed for redacting cleartext that may contain secrets before printing it (diff output, error messages, etc.).

We deliberately do not anchor the replacement to word boundaries: a secret token is usually high-entropy and its longest match is what we want to redact wherever it appears.

func ReReferenceCanonical

func ReReferenceCanonical(c *source.Canonical, against *source.Canonical, sec, env Resolver)

ReReferenceCanonical restores ${secret:…} placeholders in c — a canonical reconstructed from a destination (or resolved by SubstituteCanonical) — using against, the current templated source, as the reference. It is the inverse of SubstituteCanonical and the single boundary through which a resolved / ingested canonical is converted back toward the templated source form before any write-back. apply substitutes ${secret:…} to cleartext into the destination; capture reads that destination back, so without this re-reference a live credential would be persisted into ~/.agentsync (often a committed dotfiles repo).

Each secret-bearing field is decided FIELD-LOCALLY, from its own source counterpart, so re-referencing can never over-mask a value the user typed as a literal in an unrelated field:

  • FIELD-POSITIONAL restore (primary): an unchanged templated field whose source counterpart resolves to the same value is restored to its ${secret:…} placeholder. A field the source did NOT template (e.g. command = "npx") is never rewritten here, even if its literal happens to equal some secret's value.
  • VALUE-BASED fallback (for structural edits the positional pass can't see — a shifted MCP arg index, a renamed env/header key or server id, a secret embedded in an edited command): if the field's source counterpart was templated, re-reference its resolved cleartext (substring); if the field has NO counterpart (shifted/renamed), re-reference only when the WHOLE value is a known secret. A field whose counterpart is a literal is left untouched. ${env:…} is never inverted.

Both c and against are walked through the one walkSecretFields enumeration, so a new secret-bearing field is re-referenced automatically. Hooks have no stable id, so they are matched by event + resolution (see rereferenceHook), with a value-based fallback for a hook command edited out of position.

func ResidualSecretCleartext

func ResidualSecretCleartext(ingested, against *source.Canonical, sec, env Resolver) []string

ResidualSecretCleartext is the fail-closed backstop for the dest->source write path. After ReReferenceCanonical has done its best to restore ${secret:…} placeholders, this scans the about-to-be-written canonical for a resolved secret that would still persist as CLEARTEXT into the source (a committed dotfiles repo) — the dangerous class re-reference alone cannot fully close, because by value it cannot tell "moved/rotated secret" from "deliberate non-secret literal edit." capture.Capture refuses to write when this returns anything (it errs toward refusing rather than guessing).

`ingested` is the re-referenced canonical about to be written; `against` is the current templated source. Two leak shapes, evaluated per secretGroup:

  • VALUE: a live vault secret value appears verbatim in any field of the group — a secret moved into a field whose source counterpart is a literal, so re-reference left it unmasked.
  • SLOT: a ${secret:K} the source group referenced is ABSENT from the entire ingested group — a rotated/edited secret value re-reference couldn't match (its cleartext, or a now-unreferenced credential, would persist). A legitimately *shifted* secret keeps its placeholder somewhere in the group, so it does NOT trip this.

Returns human-readable group descriptions (empty = safe to write).

func ResolveAgeFile

func ResolveAgeFile(cfg source.SecretsConfig, agentsyncHome, userHome string) string

ResolveAgeFile returns the absolute path to the encrypted secrets file. An empty cfg.File falls back to DefaultAgeFile. ${env:HOME} and a leading ~ are expanded via userHome; relative paths are joined under agentsyncHome.

func ResolveIdentityFile

func ResolveIdentityFile(cfg source.SecretsConfig, agentsyncHome, userHome string) string

ResolveIdentityFile returns the absolute path to the age identity (private key) file, expanding ${env:HOME} / leading ~ via userHome and joining relative paths under agentsyncHome. An empty identity_file returns "".

The init template ships identity_file = "${env:HOME}/.config/agentsync/age.key"; without this expansion AgeBackend.load would os.ReadFile the literal "${env:HOME}/..." string and fail at apply time even though doctor/verify (which expanded it for their stat check) reported the config healthy.

func SubstituteRefs

func SubstituteRefs(s string, secrets Resolver, env Resolver) (string, []string, error)

SubstituteRefs walks s and replaces ${secret:dotted.key} and ${env:NAME} references. Unknown references are left as-is and reported in the returned []string of unresolved markers (caller decides whether to error).

func UnresolvedSecretRefs

func UnresolvedSecretRefs(c *source.Canonical, sec, env Resolver) []string

UnresolvedSecretRefs returns the sorted, de-duplicated set of ${secret:…} / ${env:…} references in c that cannot be resolved now (formatted "secret:KEY" / "env:KEY"). Callers that print or write content derived from a destination file a prior apply wrote (notably `agentsync diff`) use this to fail closed: if a reference cannot be resolved now, the cleartext value substituted into that on-disk file on the last apply cannot be redacted, so the safe action is to refuse rather than leak it.

${env:…} refs are included: an env var that was set at apply time (so its value landed in the dest in cleartext) can be UNSET at diff/capture time, and the redaction map (CollectResolved) skips it precisely then — exactly the case that would leak. They are not "always available".

Walks the single walkSecretFields field set, shared with SubstituteCanonical, CollectResolved, and ReReferenceCanonical.

func ValidateVaultTOML

func ValidateVaultTOML(data []byte) error

flatten recursively converts a nested map[string]any into a flat map[string]string with dotted keys, e.g. {"github": {"token": "x"}} -> {"github.token": "x"}.

Non-string leaf values (numbers, bools, arrays, datetimes) are rejected rather than coerced via fmt.Sprint: a TOML `token = 0123` would otherwise resolve to "123" (or "83" for octal) and an array to Go's "[a b]" syntax, substituting a silently-wrong credential into the user's agent config. ValidateVaultTOML parses decrypted vault bytes as TOML and applies the exact contract apply uses at resolve time (flatten: string-only leaves, no dup/colliding keys). `secrets edit` calls it so a vault that apply would later refuse is rejected at save time rather than silently encrypted.

Types

type AgeBackend

type AgeBackend struct {
	AgeFile      string // path to secrets.age
	IdentityFile string // path to age identity (private key)
	// contains filtered or unexported fields
}

AgeBackend reads an age-encrypted TOML file (secrets.age), decrypts it using the identity file specified in agentsync.toml secrets, parses as TOML, and resolves dotted keys. Cleartext is held in memory only and never written to durable storage.

func NewAgeBackend

func NewAgeBackend(ageFile, identityFile string) *AgeBackend

NewAgeBackend returns an AgeBackend configured with the given file paths.

func (*AgeBackend) Resolve

func (b *AgeBackend) Resolve(dottedKey string) (string, error)

Resolve returns the cleartext value for a dotted key like "github.token".

type EnvBackend

type EnvBackend struct{}

EnvBackend resolves ${env:NAME} via os.Getenv(NAME). Used both as the env resolver in SubstituteRefs and as the "backend = env" mode where secrets are also stored as env vars (e.g. by direnv / 1Password CLI).

func (EnvBackend) Resolve

func (EnvBackend) Resolve(key string) (string, error)

type NopResolver

type NopResolver struct{}

NopResolver is a Resolver that always returns an error (key not found). Used when no secrets backend is configured.

func (NopResolver) Resolve

func (NopResolver) Resolve(key string) (string, error)

type Resolved

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

Resolved is a canonical model prepared for rendering to an agent destination. It is a DISTINCT type — not assignable to source.Canonical and carrying no exported field of that type — so the compiler forbids handing it to the dest->source write path (capture.Capture and source.Write* accept only the templated source.Canonical / its sub-structs). That makes the recurring leak — resolved cleartext persisted back into ~/.agentsync — a compile error rather than a code-review catch.

Obtain one of two ways, both honest about what they contain:

  • SubstituteCanonical: the apply/update path. Secrets are resolved to cleartext; the result MUST only flow forward (Render -> destination).
  • ForRender: the diff/status/reconcile/explain/import path. The canonical is wrapped as-is (still templated, or ingested-from-dest) because those callers render only to hash, preview, or enumerate — never to write real config — so resolution is irrelevant and must not be forced (it would fail when the secrets backend is locked).

The single resolved->templated converter is ReReferenceCanonical; there is no method that turns a Resolved back into a writable source.Canonical.

func ForRender

func ForRender(c source.Canonical) Resolved

ForRender wraps a templated (or ingested-from-destination) canonical as a Resolved for rendering, WITHOUT resolving any ${secret:…}. Use it from paths that render only to compute hashes / previews / owned pointers.

func SubstituteCanonical

func SubstituteCanonical(c source.Canonical, sec Resolver, env Resolver) (Resolved, error)

SubstituteCanonical resolves every secret-bearing string field of c (see walkSecretFields for the authoritative set) — any ${secret:...} / ${env:...} reference — and returns the result as a Resolved. The input c is left untouched (it stays the templated source form); resolution happens on an internal copy, so a resolved cleartext value can never alias back into the caller's source.Canonical.

The Resolved return type is what makes the apply model render-only: it cannot be handed to source.Write* or capture.Capture (compile error). If any reference cannot be resolved, it returns an error listing all unresolved markers — apply is blocked, never silent about a missing secret.

func (Resolved) Canonical

func (r Resolved) Canonical() source.Canonical

Canonical returns the underlying model for the render layer to read. It is the render-only egress: the adapter Render entry points consume it to project the resolved model into destination FileOps.

It returns a writable source.Canonical, so this accessor is the one seam that could otherwise launder resolved cleartext back toward source. The type wall makes passing a Resolved DIRECTLY to source.Write* / capture.Capture a compile error; this accessor is additionally fenced by a forbidigo rule (.golangci.yml) that forbids secrets.Resolved.Canonical outside the adapter Render files, so non-render code can't unwrap-then-write. The dest->source direction goes through ReReferenceCanonical + capture.Capture on a templated source.Canonical, never through here.

ACCEPTED RESIDUAL (documented, intentional): the forbidigo fence is a static matcher, so interface dispatch, struct embedding, and reflection can defeat it; and source.Write* is itself not fenced. So a DELIBERATE two-step laundering (defeat the fence to get a writable source.Canonical, then call a source writer that skips re-referencing) remains possible. No innocent mistake produces this, and capture.Capture always re-references, so every real import/reconcile flow is safe. Fencing the whole source.Write* API was declined (it fights the legitimate write path and is bypassable one level down). If you find yourself unwrapping a Resolved outside an adapter Render, stop — you almost certainly want capture.Capture instead.

type Resolver

type Resolver interface {
	Resolve(key string) (string, error)
}

Resolver returns the cleartext value for a key like "github.token". An unknown key returns an error.

func SelectBackend

func SelectBackend(cfg source.SecretsConfig, agentsyncHome, userHome string) Resolver

SelectBackend returns the appropriate Resolver for the given SecretsConfig. For "age" backend it returns an AgeBackend; for "env" or empty it returns EnvBackend.

agentsyncHome anchors relative cfg.File / identity_file paths; userHome (paths.HomeDir) expands ${env:HOME} and a leading ~. Both the age file (defaulted via ResolveAgeFile) and the identity file (expanded via ResolveIdentityFile) are resolved here so apply/verify/diff agree with the `secrets` subcommands on which files to read.

Jump to

Keyboard shortcuts

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