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
- func CheckIdentityPermissions(path string) error
- func CollectResolved(c *source.Canonical, sec, env Resolver) map[string]string
- func Decrypt(ageFile, identityFile string) ([]byte, error)
- func DeleteNestedKey(m map[string]any, key string) bool
- func Encrypt(plaintext []byte, recipient string, dest string) error
- func FlattenKeys(m map[string]any) []string
- func GetNestedKey(m map[string]any, dottedKey string) (any, bool)
- func MalformedSecretRefs(c *source.Canonical) []untrusted.Text
- func MaskResolved(s string, resolved map[string]string) string
- func NormalizeBackend(b string) string
- func ReReferenceCanonical(c *source.Canonical, against *source.Canonical, sec, env Resolver)
- func ReadVault(path string) ([]byte, error)
- func RequireAgeVault(cfg source.SecretsConfig, op string, access VaultAccess) error
- func ResidualSecretCleartext(ingested, against *source.Canonical, sec, env Resolver) []string
- func ResolveAgeFile(cfg source.SecretsConfig, agentsyncHome, userHome string) string
- func ResolveIdentityFile(cfg source.SecretsConfig, agentsyncHome, userHome string) string
- func SecretRefsByComponent(c *source.Canonical) map[RefLocation][]string
- func SetNestedKey(m map[string]any, dottedKey, value string) error
- func SubstituteRefs(s string, secrets Resolver, env Resolver) (string, []string, error)
- func UnresolvedSecretRefs(c *source.Canonical, sec, env Resolver) []string
- func ValidateVaultTOML(data []byte) error
- type AgeBackend
- type ComponentID
- type EnvBackend
- type Field
- type Finding
- type NopResolver
- type RefLocation
- type Resolved
- type Resolver
- type Severity
- type Vault
- type VaultAccess
Constants ¶
const ( BackendAge = "age" BackendEnv = "env" )
The secrets.backend values agentsync understands. These are the exact strings SelectBackend switches on AFTER NormalizeBackend.
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.
const SkipPermCheckEnv = "AGENTSYNC_AGE_SKIP_PERM_CHECK"
SkipPermCheckEnv is the env var that disables CheckIdentityPermissions' PERMISSION gate. Set to "1" for setups (network homes, dev containers) where mode bits don't reflect actual access (e.g. NFS roots squash, ACLs override). It does NOT disable the regular-file check: that one is not about mode bits, and switching it off would restore an unkillable hang. See CheckIdentityPermissions.
Variables ¶
This section is empty.
Functions ¶
func CheckIdentityPermissions ¶
CheckIdentityPermissions is the shared pre-read gate on the age identity file: every path that goes on to os.ReadFile it — AgeBackend.load (apply, `secret get`/`list`/`set`/`edit`/`remove`, doctor's checkSecretReferences) and Decrypt — calls this first. It enforces two rules.
SHAPE: the path must be a REGULAR file. A directory, socket or FIFO is not an age identity, and a FIFO is the sharp one — os.ReadFile on a FIFO with no writer does not fail, it BLOCKS FOREVER. Before this arm, a 0600 FIFO at secrets.identity_file cleared the permission gate and wedged every command that read the vault: measured against a vault that exists, with a live ${secret:…} reference and a valid recipient, `secret get`, `secret list`, `secret set`, `secret edit`, `secret remove`, `apply --dry-run` and `doctor` all hung until killed — `doctor` after printing its own ✗ for the same file. Only `agentsync check` was clean, because it stops at secrets.ValidateConfig and never reads. Putting the rule on the surfaces that REPORT (validateIdentity) and not on the gate that READS is what left that gap; it belongs here, where one arm closes it for every caller at once. (issue #228)
PERMISSIONS: the file must not be readable by group or other. A 0o644 identity file defeats the purpose of encryption. On Windows the mode bits are not POSIX semantics, so that rule is skipped there, and users can opt out with AGENTSYNC_AGE_SKIP_PERM_CHECK=1.
The shape check is deliberately placed AHEAD of both of those escapes. The Windows caveat and the env override exist because MODE BITS can be meaningless; a FIFO is a FIFO on every OS and under every override, so letting either one skip past the shape rule would hand the hang straight back to the users most likely to set it. It is also ahead of the permission rule for the reason validateIdentity orders them the same way: telling the owner of a 0755 directory to `chmod 600` names the wrong problem.
The name is narrower than the contract — kept because this is the single gate every read path already calls, and a second entry point beside it would be fail-OPEN by construction: a future reader that picked the permissions-only one would silently reopen the hang. That is also why the vault's twin rule took a different shape: openVault RETURNS THE HANDLE, because the vault had no established gate to extend and a fresh Check* would be exactly the forgettable call this paragraph argues against. The identity cannot use that shape — its read is os.ReadFile, which hands back bytes, not a file.
func CollectResolved ¶
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 ¶
Decrypt decrypts an age-encrypted file using the identity in identityFile and returns the plaintext bytes.
func DeleteNestedKey ¶ added in v0.15.0
DeleteNestedKey removes a dotted key path from the decrypted vault map, reporting whether a VALUE was removed. A path naming an intermediate table is refused (ok=false): deleting a whole subtree on a typo'd key is not a thing a one-word command should do silently.
func Encrypt ¶
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 FlattenKeys ¶ added in v0.15.0
FlattenKeys walks the decrypted vault and returns dotted key paths. Only the PATHS are collected — no value ever leaves this function.
func GetNestedKey ¶ added in v0.15.0
GetNestedKey retrieves a dotted key from a nested map, returning the raw value so the caller can enforce the string-only contract (apply rejects non-string leaves; `get` must not print a value apply would refuse).
func MalformedSecretRefs ¶ added in v0.11.0
MalformedSecretRefs walks every secret-bearing canonical field and returns each ${secret:…}/${env:…}-SHAPED token that does NOT match the strict reference shape (a non-empty [A-Za-z0-9._-]+ key after a colon) — e.g. ${secret:} or ${secret foo}. It needs no backend, so it is the offline-validatable shape check `verify` runs when reference resolution is skipped (issue #171).
The returned tokens are the raw, loosely-matched candidates — they are config-derived and therefore attacker-influenceable (agentsync configs are shareable dotfiles), and looseRefRe's `[^}]*` tail can capture ESC / C1 / newline bytes that the strict shape rejects. They are returned as untrusted.Text so any display site (the `verify` offline error) sanitizes them on print BY DEFAULT — a raw print cannot reintroduce the #93/PR100 terminal-escape-injection class. Match/dedup/sort run on the raw bytes; only the returned rendering is display-guarded.
func MaskResolved ¶
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 NormalizeBackend ¶ added in v0.15.0
NormalizeBackend folds a raw secrets.backend value to the form SelectBackend switches on.
It is strings.ToLower and NOTHING ELSE. In particular it does NOT trim whitespace, because SelectBackend does not either: trimming here would make a validator bless a `" age"` that apply then resolves as NopResolver — the mirror image of the bug this function exists to kill. SelectBackend and ValidateConfig both route through it, so the resolver apply renders through and the validator that gates it cannot drift on backend spelling. Backend names are therefore matched CASE-INSENSITIVELY on both sides: `"AGE"`, `"Age"` and `"age"` are one value. (issue #228)
func ReReferenceCanonical ¶
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 ReadVault ¶ added in v0.15.0
ReadVault reads the whole encrypted vault at path, refusing any path that is not a regular file before the open. It is the whole-file form of openVault; its one production caller is Vault.WriteVerified's rollback snapshot, in this package, and it is exported so the secrets_test shape-gate table (age_fifo_unix_test.go) can assert this read too. See openVault for why the gate returns data rather than a verdict.
func RequireAgeVault ¶ added in v0.15.0
func RequireAgeVault(cfg source.SecretsConfig, op string, access VaultAccess) error
RequireAgeVault reports why cfg cannot serve op, or nil.
This is a DIFFERENT rule from ValidateConfig, deliberately: the `secret` subcommands manage the age-encrypted vault specifically, so `backend = "env"` is a legitimate refusal for them even though it is a perfectly valid configuration for apply. What they must NOT do is invent their own idea of how a backend name is spelled — five hand-written copies of `cfg.Backend != "age"` refused a `backend = "AGE"` that apply resolves cleanly (issue #228). Routing through NormalizeBackend fixes all five at once, so these commands accept an age backend in any casing.
op is the command as the user types it today ("secret set", "secret edit"), so a refusal names a live command rather than a retired spelling — two of the copies this replaces still said `secrets edit` / `secrets set`, renamed to the singular group with no alias in #200 F4.
func ResidualSecretCleartext ¶
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 SecretRefsByComponent ¶ added in v0.11.0
func SecretRefsByComponent(c *source.Canonical) map[RefLocation][]string
SecretRefsByComponent returns, per canonical component, the ${secret:…}/${env:…} REFERENCES its secret-bearing fields carry — the placeholder text, never a resolved value, and no backend is consulted.
It is for diagnostics that must report *that* a reference resolves at a location (`agentsync explain <path>`) without emitting any secret material. Like every other secret operation it is a thin caller of walkSecretFields, so a field added to the one authoritative list is picked up here automatically.
Each component's refs are deduped and sorted; a component with no references is absent from the map.
func SetNestedKey ¶ added in v0.15.0
SetNestedKey sets a dotted key in a nested map, creating intermediate maps as needed. It refuses *destructive type changes* rather than silently destroying vault content — the secrets.age vault is often the only copy of a cleartext secret, so an overwrite here is irreversible loss:
- Nesting under an existing scalar parent (e.g. `set token.scope=…` when `token` is already a scalar secret) would drop the parent's value; refused.
- A leaf assignment that would overwrite an existing table (e.g. `set a=…` when `a` is a `[a]` table) would drop the whole sub-table; refused.
The legitimate cases still succeed: an in-place scalar update (scalar leaf → new scalar leaf) and a new nested key under an absent or table parent. Error messages name the FULL dotted paths the user typed (e.g. `a.b.c` clashing with scalar `a.b`, not the recursion's local `b.c`/`b`) and carry only KEY names, never a secret *value* byte (the no-secret-in-stderr convention honored by the CLI's resolveSecretKeyValue).
m must be non-nil: a nil map cannot be assigned into, and this function has no way to hand a fresh one back. Vault.Load returns a writable map even for an empty vault, so its callers never see that panic.
func SubstituteRefs ¶
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 ¶
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 ¶
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.
type ComponentID ¶ added in v0.11.0
ComponentID is a (kind, name) pair for a component id an adapter joins into a destination path. For a TEXT component (subagent/command/skill) the Name is the canonical Name that becomes a destination filename stem at Render. For an MCP or LSP server the Name is the server id: a filename stem only for adapters that write one file per server (continuedev — filepath.Join(MCPDir, id+".yaml")), and a JSON key in a shared file for the rest — but a traversal id is never a legitimate server id in either shape, so all kinds are validated the same way. Kind is the label source.ValidateComponentID expects ("subagent", "command", "skill", "mcp", "lsp").
type EnvBackend ¶
type EnvBackend struct{}
EnvBackend resolves ${env:NAME} via os.LookupEnv(NAME) — presence, not emptiness, is the criterion, so a var set to "" resolves to "" (matching AgeBackend) and only an unset var errors. 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).
type Field ¶ added in v0.15.0
type Field string
Field names the secrets key a Finding is about, so a caller can label or group findings — a padded report column, say.
It is never redundant with the message, because the messages are FIELD-RELATIVE by construction: not one of them names its own key ("set", "age", "missing — required for backend = …", a bare resolved path). That is what lets a single validator read well on both surfaces — `check` composes `<field>: <message>` into one error line, `doctor` prints the message under a padded label column — and a message that named its key would stutter on both. The corollary is that a surface which DROPS the Field prints an unlabelled line; the vault path is the sharp case, since secrets.file is DEFAULTED, so a bare "<path> — not readable" names a path the user never wrote. Keep new messages field-relative.
type Finding ¶ added in v0.15.0
type Finding struct {
Field Field
Severity Severity
// Message is display-safe BY CONSTRUCTION: untrusted.Text sanitizes on
// String(), which fmt's %s/%v invoke, so a [secrets].identity_file / file
// path carrying raw ESC or bidi bytes — these are shareable dotfile values —
// cannot inject terminal escapes into an error or a report that prints the
// Message through fmt (issue #93/#171).
//
// The whole formatted message is wrapped ONCE, rather than each path
// operand being wrapped individually. That is deliberate and stronger: a
// *fs.PathError's own Error() re-embeds its raw path, so the per-operand
// style leaked the path a second time through %v (see the WRAPPED-ERROR
// HAZARD note in the untrusted package doc). Nothing here is %w-wrapped, so
// the Text owns the entire string.
Message untrusted.Text
}
Finding is one observation about the secrets block.
func ValidateConfig ¶ added in v0.15.0
func ValidateConfig(cfg source.SecretsConfig, agentsyncHome, userHome string) []Finding
ValidateConfig is the SINGLE definition of a valid secrets block.
It lives here, beside SelectBackend, because a validator that disagrees with the resolver it gates is worse than no validator: before #228 the secrets contract was written out seven times across internal/cli (`check`, `doctor`, and five `secret`-subcommand gates), and those copies disagreed with each other AND with apply — `backend = "env"` passed `check` and failed `doctor`, and `backend = "AGE"` applied cleanly while failing both. This function is the one definition they collapse onto.
It emits AT MOST ONE finding per Field, in backend → recipient → identity_file → file order, so a caller can render one line per field in a stable order. agentsyncHome anchors relative identity/vault paths and userHome expands ${env:HOME} / a leading ~ — the same two anchors SelectBackend uses, passed the same way (the USER agentsync home at every scope, since a project tree inherits the user's vault).
It is built for two consumption shapes: an error-or-nil surface errors on the SeverityFail findings and ignores the rest; a report surface renders every finding as a line and counts the SeverityFail ones as issues.
type NopResolver ¶
type NopResolver struct{}
NopResolver is a Resolver that always returns an error (key not found). Used when no secrets backend is configured.
type RefLocation ¶ added in v0.11.0
RefLocation identifies the canonical component a secret reference lives on. Kind is "mcp", "lsp", or "hook"; ID is the server id or the hook event.
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 ¶
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 ¶
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 ¶
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.
func (Resolved) ComponentIDs ¶ added in v0.11.0
func (r Resolved) ComponentIDs() []ComponentID
ComponentIDs returns every component id reachable in the resolved model that an adapter joins into a destination FileOp path — subagent, command, and skill names plus MCP and LSP server ids, INCLUDING the project overlay — so a caller (render.Plan) can validate each against source.ValidateComponentID before any adapter joins it into a path. A Name/id like "../../../tmp/x" would otherwise render a path that escapes the agent's config dir.
It is deliberately a STRING-only accessor: it reads the id strings off the private canonical directly (never through the fenced Canonical() egress) and never returns the writable source.Canonical. So it neither trips the secrets.Resolved.Canonical forbidigo fence nor gives the caller a seam to launder resolved cleartext back toward source — the dispatch-layer guard gets exactly the names it must validate and nothing more.
func (Resolved) ForAgent ¶ added in v0.14.0
ForAgent returns a Resolved narrowed to what the named agent should render: every component provided by a plugin whose `agents` allowlist excludes this agent, or whose `native_agents` deferral list names it, is dropped. Hand-authored components (no Plugin stamp) always survive.
It is the render-side entry to source.FilterForAgent, which holds the actual rule and has this as its ONLY caller. Note that import's capture-refusal filter deliberately does NOT narrow the same way — its refusal set is wider than the render set on purpose, because the destination can still hold un-reclaimed output from before a deferral was recorded. Do not "restore symmetry" between the two; see source.FilterForAgent. This wrapper exists for a fence reason and a design reason:
- Fence: render.Plan cannot reach the underlying source.Canonical (the secrets.Resolved.Canonical forbidigo rule confines that to adapter Render), so the narrowing has to be a method on the wrapper. It reads the private canonical directly and returns another Resolved — no writable model is ever exposed, so this adds no laundering seam.
- Design: doing it once at the dispatch waist means no adapter can forget. An allowlist honoured in ten adapters × six component kinds is sixty chances to silently render the duplicate the allowlist exists to prevent.
It never touches secret material — targeting is a pure include/exclude over already-resolved components — so a narrowed Resolved carries exactly the substitutions its parent did.
The result SHARES component values with the receiver (the filter rebuilds slices, it does not deep-copy). That matches what Render already gets today: adapters read the model and build their own output, they never mutate it.
type Resolver ¶
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 Resolver apply renders through for the given secrets config. "age" (in any casing) selects an AgeBackend; "env" selects the EnvBackend; ANY OTHER VALUE — including an EMPTY backend — selects NopResolver, which errors on every ${secret:…} lookup.
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/check/doctor/diff agree with the `secret` subcommands on which files to read.
The backend name is folded through NormalizeBackend — the same helper ValidateConfig uses — so no validator built on it can reject a spelling apply accepts.
type Severity ¶ added in v0.15.0
type Severity int
Severity ranks a Finding.
The four tiers exist so that a report surface (`agentsync doctor`) can derive its whole secrets-block report from this validator — including the lines that pass — instead of restating which fields exist, in what order, and what counts as healthy. Two independently hand-written copies of exactly that is how `check` and `doctor` came to disagree about the same block (issue #228). The BLOCK's report, not doctor's whole Secrets section: doctor prints these lines and then its own ${secret:…}/${env:…} reference-resolution lines under the same heading, and resolvability is not this validator's business. An error-or-nil surface (`agentsync check`) has one verdict to give and acts on SeverityFail alone.
const ( // SeverityOK — the field is configured correctly. // // It is deliberately the ZERO value, so a Finding nobody filled in would // render as a pass. That is safe only because no such Finding exists: every // Finding in this package is built by finding(), which takes a Severity // explicitly, and both consumers read them straight back from // ValidateConfig. Note what this is NOT: the defensive `default:` arms in // doctor's checkSecrets and check's secretsProblems guard against a NEW TIER // added here without a home there, and a new tier takes a distinct value // wherever it is inserted in this block — the named arms keep routing // correctly and the newcomer lands on `default:` (doctor warns rather than // printing a ✓; check errors rather than exiting 0). A // `severityUnset = iota` sentinel would therefore renumber an exported type // to guard a state nothing can produce; declined deliberately. SeverityOK Severity = iota // SeverityInfo — there is nothing to check (no [secrets] block at all). SeverityInfo // SeverityWarn — usable now, but something is missing or will bite later. SeverityWarn // SeverityFail — the configuration is broken. SeverityFail )
type Vault ¶ added in v0.15.0
type Vault struct {
// contains filtered or unexported fields
}
Vault is the age-encrypted secret store as a read-modify-write subject: the two paths it lives at, the decrypted document, and the verified save.
Every one of these operations used to live in `internal/cli/secrets.go`, which put cleartext-vault handling — decrypt, mutate, re-encrypt, verify, roll back — in the cobra layer, one package away from the guards that exist to keep cleartext out of the canonical source. None of it ever needed a *cobra.Command.
What a Vault deliberately does NOT do: it never touches source.Write*, never unwraps a Resolved, and never reaches the canonical source at all. Its file is secrets.age, the input side of resolution; the leak the rest of this package guards against is a RESOLVED value flowing the other way, into ~/.agentsync's TOML. A Vault value is what an AgeBackend later reads, not something written back through capture.
A Vault is a value: the caller resolves the three inputs and hands them over, which is what keeps this package cobra-free and free of internal/paths.
func NewVault ¶ added in v0.15.0
func NewVault(cfg source.SecretsConfig, agentsyncHome, userHome string) Vault
NewVault binds a vault to its configuration. agentsyncHome anchors relative secrets paths; userHome expands ${env:HOME} and a leading ~ inside them — pass paths.HomeDir(env), so AGENTSYNC_TARGET_ROOT is honoured exactly as the apply path honours it.
func (Vault) AgeFile ¶ added in v0.15.0
AgeFile returns the absolute path to the secrets.age file, applying the same default + ${env:HOME}/~ expansion the apply path uses.
func (Vault) IdentityFile ¶ added in v0.15.0
IdentityFile returns the absolute identity_file path, expanding ${env:HOME}/~ the same way the apply path does — without this, `secret get/set/edit` would os.ReadFile a literal "${env:HOME}/..." string and fail even though the documented init template uses exactly that form.
func (Vault) Load ¶ added in v0.15.0
Load decrypts secrets.age and returns the top-level map. If the file does not exist, returns an empty map.
func (Vault) Save ¶ added in v0.15.0
Save marshals m as TOML and encrypts it to secrets.age, verifying the result is decryptable by the configured identity (see WriteVerified).
Before persisting, it validates the marshaled bytes against apply's flatten contract (ValidateVaultTOML: string-only leaves, no dup/colliding keys) — the SAME guard the `secret edit` path applies — so a `set` that would yield a vault apply later refuses is rejected at save time instead of being silently encrypted. Validation runs on the EXACT bytes that get encrypted (one marshal), so validated bytes == written bytes; nothing can drift between the check and the write.
func (Vault) WriteVerified ¶ added in v0.15.0
WriteVerified encrypts plain to the age store and then verifies the configured identity_file can actually decrypt the result, rolling back the previous store on failure. Without this, a recipient that does not match the identity (a typo, or a key swap) silently re-encrypts the whole store to a key the user cannot read — locking them out of their own secrets with a cheerful "set" message.
It does NOT validate plain against apply's flatten contract: Save does, on the bytes it marshals itself. A caller that brings its own bytes — `secret edit`, handing back what the editor wrote — MUST run ValidateVaultTOML first, as that caller does; this method's job is the encrypt-verify-roll-back step and nothing about the document's shape.
type VaultAccess ¶ added in v0.15.0
type VaultAccess int
VaultAccess says which age-vault operation a `secret` subcommand performs.
const ( // VaultRead only reads the vault (`secret get`, `secret list`). VaultRead VaultAccess = iota // VaultWrite re-encrypts the vault (`secret set`, `secret edit`, // `secret remove`), decrypting it first when one exists, so it // additionally needs a recipient. It does NOT always read: against a // not-yet-created vault these commands write without decrypting anything. VaultWrite )