Skip to content

Blog

SecretSpec 0.14: Secret References

SecretSpec keeps a secretspec.toml that declares what secrets an application needs, and resolves the values from a provider: your system keyring, 1Password, Vault, a .env file, and so on. Until now it stored every secret under a naming convention it controlled, secretspec/{project}/{profile}/{key}, and that convention was the only place it looked.

That works when SecretSpec created the secret. It does not when the secret already exists under a name something else chose: a db item in a 1Password vault, a myapp/config path at a Vault mount, an environment variable your platform already sets. To manage such a secret you had to copy its value into SecretSpec’s convention, leaving two copies to rotate, or leave it out of SecretSpec entirely.

SecretSpec 0.14 introduces ref. A secret can name one that already exists, by the store’s own coordinates, and SecretSpec reads and writes that secret in place:

[profiles.production]
DATABASE_URL = { description = "Postgres DSN", ref = { item = "db", field = "password" }, providers = ["prod_op"] }

DATABASE_URL now resolves from the password field of the 1Password item db. SecretSpec does not prepend a project or profile, and does not create a name of its own.

1Password will give you an address for that field: op://Production/db/password. The obvious design is to accept that string in the config and be done. We built that first and removed it.

A string like op://Production/db/password names the store and the secret at the same time, which ties the secret to 1Password. The same reference cannot then resolve from Vault in CI and 1Password on a laptop, cannot be redirected at a .env fixture for a test run without editing the manifest, and does not compose with a provider fallback chain, because the chain and the address disagree about where the secret is.

SecretSpec already decides which store to use, through providers, profiles, fallback chains, and the --provider override. A ref names only the secret and leaves the store to that existing machinery.

A ref is a table, not a URL. Each key names a level of structure that some stores have:

vault which container holds the item (1Password only)
└── item the store's own name for the secret (always required)
└── section a named group of fields (1Password only)
└── field one component inside the item (structured stores)
└── version which revision to read (GCSM only)

Only item is required, because every store names its secrets somehow. item is the complete name: it replaces the whole convention path, with no project or profile and no folder prefix prepended. ref = { item = "GITHUB_PAT" } on the env provider reads the environment variable GITHUB_PAT and nothing else.

The other keys refine item for stores that have that structure. A .env key holds a single value, so field on a dotenv ref is not meaningful. A Vault KV entry is a map, so field is required. When a store has no equivalent for a coordinate it reports an error naming that coordinate, rather than reading a different secret:

GITHUB_TOKEN = { description = "GitHub token", ref = { item = "GITHUB_PAT", field = "x" }, providers = ["env"] }
Error: Provider operation failed: the env provider does not support the `field` coordinate. Drop `field` from the ref for `GITHUB_PAT`.

All eleven providers resolve refs, and each rejects the coordinates it cannot represent. A store whose secrets have no internal parts gets that rejection from shared code, without any per-provider work.

A ref supplies the name only. Which provider resolves it follows the normal provider resolution order: a --provider override, then the secret’s providers chain, then profile and global defaults. That is the same order every other secret uses.

Because the store is not part of the reference, the same ref works across providers. Each provider in a fallback chain is asked for the same coordinates, and one that cannot interpret them logs a warning and the chain continues:

[profiles.production]
DATABASE_URL = { description = "Postgres DSN", ref = { item = "db", field = "password" }, providers = ["onepassword://Production", "keyring"] }

Chain entries can also be inline scheme:// URIs, as above, with no [providers] alias declared first.

The --provider override redirects a referenced secret the same way it redirects a conventional one, so pointing a whole suite at a .env fixture needs no change to the manifest:

Terminal window
$ secretspec run --provider dotenv:.env.fixtures -- cargo test

Reads and writes use the same coordinates. secretspec set and interactive check write to the referenced secret in place wherever the store supports writes:

Terminal window
$ secretspec set DATABASE_URL
# writes the `password` field of the 1Password item `db`, in place

1Password edits the field with op item edit and does not create items. Keyring, pass, dotenv, Bitwarden, Proton Pass, and LastPass write their refs as well. Vault, AWS Secrets Manager, and Google Secret Manager are read-only for refs and report that directly, rather than claiming the provider cannot write at all.

A ref also composes with generate. If the referenced secret does not exist yet, SecretSpec generates the value and writes it to the coordinates, so the first check populates the item everything else already reads.

check, run, and the SDKs now group secrets by store and fetch the groups concurrently instead of one store after another. Within a group, referenced secrets use the store’s bulk API where it has one (AWS BatchGetSecretValue, and the single Bitwarden, Proton Pass, and 1Password listings) and otherwise resolve concurrently, fetching each unique coordinate once. CLI authentication for 1Password, LastPass, and Proton Pass is probed once per account or session instead of once per provider instance.

Terminal window
cargo install secretspec

Three changes to be aware of:

  • A onepassword:// URI carrying an item path used to drop the path and target a vault literally named vault. Item paths, including pasted op://vault/item/field strings, now fail with an error that gives the ref table to write instead. Provider URIs are store addresses only.
  • ref is always a table. String and URI forms are rejected, with the same translation in the error.
  • Manifest validation now runs on every load. Rules that secretspec.toml documents (a required secret cannot have a default, generate needs a type, ref coordinates must be non-empty) are enforced on load rather than ignored. A manifest that violated one of them will now fail with a clear error.

See Secret References for the full model and the configuration reference for how each provider maps the coordinates. Questions or feedback? Join us on Discord.

SecretSpec 0.13: SDKs for Python, Node.js, Go, Ruby, and Haskell

SecretSpec separates what secrets an application needs, declared in secretspec.toml, from where the values live, a provider like your system keyring, 1Password, or Vault. Until now, reading those resolved secrets at runtime meant the CLI or the Rust SDK. If your service was written in Python or Go, you shelled out to secretspec run or reimplemented resolution yourself.

SecretSpec 0.13 closes that gap. It ships native SDKs for five languages: Python, Node.js / TypeScript, Go, Ruby, and Haskell. Each resolves the exact secrets your manifest declares, through the same providers, profiles, fallback chains, and generators as the CLI, with no per-language configuration.

Every SDK is a thin client over the same Rust core that powers the CLI. No provider logic, profile resolution, chain fallback, as_path materialization, or secret generation lives in the binding. A provider added to SecretSpec works in every language the day it lands, and every SDK behaves identically.

The binding strategy is chosen per ecosystem:

  • Python: a pyo3 extension, statically linked, shipped as a self-contained cp39-abi3 wheel.
  • Node.js: a napi-rs addon with prebuilt per-platform packages.
  • Ruby: a native C extension (mkmf) with the resolver statically linked into a platform gem.
  • Go: the secretspec-ffi C ABI loaded at runtime via purego (no cgo).
  • Haskell: the same C ABI, linked at build time through the Haskell FFI.

Each SDK mirrors the vocabulary of the Rust derive crate: a builder that takes a provider, a profile, and an access reason, then load() to resolve, then a map of secrets you can read or export into the environment.

# Python
from secretspec import SecretSpec
resolved = (
SecretSpec.builder()
.with_provider("keyring://")
.with_profile("production")
.with_reason("boot web app")
.load()
)
print(resolved.secrets["DATABASE_URL"].get) # value, or file path for as_path
resolved.set_as_env() # export into os.environ
// Node.js / TypeScript
const { SecretSpec } = require('secretspec');
const resolved = SecretSpec.builder()
.withProvider('keyring://')
.withProfile('production')
.withReason('boot web app')
.load();
console.log(resolved.secrets.DATABASE_URL.get()); // value, or as_path file path
resolved.setAsEnv(); // export into process.env
// Go
resolved, err := secretspec.New().
WithProvider("keyring://").
WithProfile("production").
WithReason("boot web app").
Load()
fmt.Println(resolved.Secrets["DATABASE_URL"].Get()) // value, or as_path file path
resolved.SetAsEnv() // export into the environment
# Ruby
resolved = Secretspec::SecretSpec.builder
.with_provider("keyring://")
.with_profile("production")
.with_reason("boot web app")
.load
puts resolved.secrets["DATABASE_URL"].get # value, or as_path file path
resolved.set_as_env! # export into ENV
-- Haskell
resolved <-
S.load
( S.builder
& S.withProvider "keyring://"
& S.withProfile "production"
& S.withReason "boot web app"
)
S.setAsEnv resolved -- export into the environment

Across all of them, load() resolves every declared secret, a missing required secret raises a typed MissingRequiredError, and as_path secrets come back as a readable file path with a cleanup that removes the backing temp file. The access reason feeds the same audit log and require_reason policy from 0.12, so a Go service is as accountable as the CLI.

Under all five SDKs sits a new crate, secretspec-ffi: a small, versioned C ABI for resolving secrets. If we do not ship your language yet, you can bind to it directly. It also exposes the public Rust building blocks the SDKs share, Secrets::resolve() and Secrets::report(), so a Rust program reaches the same value-carrying and value-free entry points.

Typed secrets, one schema for every language

Section titled “Typed secrets, one schema for every language”

secretspec.toml already knows the shape of your secrets, so 0.13 can hand that shape to your type system. secretspec schema emits a JSON Schema for your manifest, the union of all profiles or one profile with --profile. Pipe it through quicktype to generate idiomatic typed classes in any language, then populate them from each SDK’s fields() map:

Terminal window
secretspec schema | quicktype -s schema --top-level SecretSpec --lang python -o secrets_gen.py
typed = Secrets.from_dict(resolved.fields())
print(typed.database_url) # typed str

One schema drives every language’s type system, with no hand-written emitter per language.

Terminal window
pip install secretspec # Python
npm install secretspec # Node.js / TypeScript
gem install secretspec # Ruby
go get github.com/cachix/secretspec/secretspec-go # Go

For Haskell, add secretspec from Hackage to your build-depends. The CLI and Rust SDK upgrade as usual:

Terminal window
cargo install secretspec

See the SDK overview for the per-language guides. Questions or feedback? Join us on Discord.

SecretSpec 0.12: audit logs and coding agents

A coding agent reaches for the same secrets you do, but on its own initiative and many times a session: a read looks identical whether it came from you running a deploy or an agent exploring the codebase.

SecretSpec 0.12 makes that access accountable. It ships three things:

  • Audit log — every secret read and write is appended to a local, per-user JSONL log. On by default. Values are never recorded.
  • Reason-on-access — secret access can require a human-readable reason, enforced for coding agents by default.
  • secretspec audit command — filter and summarize the log, or pipe raw JSON Lines to jq.

Every secret read and write, from the CLI and the Rust SDK, is appended to a local log as JSON Lines, one event per line. Secret values are never written, only metadata: the secret name, the profile, the provider that served it (with any embedded credentials redacted), the outcome, the reason, and who was asking, including the detected coding agent.

{
"v": 1,
"ts": "2026-06-04T17:04:00.893Z",
"action": "get",
"project": "my-app",
"profile": "production",
"key": "DATABASE_URL",
"provider": "keyring://",
"outcome": "found",
"reason": "deploy web frontend",
"actor": { "user": "alice", "agent": "claude-code", "is_agent": true },
"version": "0.12.0"
}

The log lives in your per-user state directory (~/.local/state/secretspec/audit.log) and is created readable only by you. Read it with any tool, or use the new secretspec audit command for filtering and a readable summary:

Terminal window
# Last 20 entries, formatted
secretspec audit -n 20
# Only `run` events for one project
secretspec audit --project my-app --action run
# Raw JSON Lines, piped to jq
secretspec audit --json | jq 'select(.outcome == "missing")'

It is configured in your user-global config (~/.config/secretspec/config.toml), not the project’s secretspec.toml, so a repository you clone can’t quietly turn off or redirect your audit log. The log is a single file capped at 1 MiB, a size-bounded recent record rather than permanent compliance history; forward it to a central system if you need that. To turn it off entirely:

~/.config/secretspec/config.toml
[audit]
enabled = false

See Audit Logging for the full record schema and options.

When a coding agent like Claude Code reaches for a secret without a reason, the access is refused and the agent is told exactly what to do next:

$ secretspec run -- npm test
Error: Accessing secrets requires a reason. Provide one with --reason
"<why you are accessing these secrets>", the SECRETSPEC_REASON environment
variable, or Secrets::with_reason() in the SDK. (Policy: require_reason in
[project] of secretspec.toml — defaults to "agents"; set it to false to
disable.)

Claude Code reads that message, states why it needs the secret, and retries:

Terminal window
secretspec run --reason "run the test suite before opening a PR" -- npm test

Both the refusal and the successful retry land in the audit log, so the reason is tied to the access. There are three ways to supply a reason:

SourceScopePrecedence
--reason flagCLIhighest
Secrets::with_reason()SDKoverrides env
SECRETSPEC_REASONCLI + SDK + derivelowest
Terminal window
# CLI: the most explicit option, overrides the others
secretspec run --reason "deploying release 0.12" -- ./deploy.sh
// SDK: the programmatic equivalent of --reason
let secrets = Secrets::load(/* ... */)?.with_reason("nightly backup job");
Terminal window
# Env: lowest precedence, but honored everywhere
export SECRETSPEC_REASON="nightly backup job"

SECRETSPEC_REASON is resolved by Secrets::load / load_from, which means secretspec-derive-generated code and other library callers satisfy the policy and supply an audit reason without any code changes.

Whichever path you use, blank or whitespace-only reasons are ignored, so they can’t quietly satisfy the policy. Under the hood this is backed by a new Provider::set_reason trait method (a no-op by default), so existing providers keep working unchanged.

The new require_reason policy in the [project] table controls when a reason is mandatory:

[project]
name = "my-app"
require_reason = "agents" # require it from agents (default), or true / false
  • "agents" (the default): require a reason only when a coding agent is detected.
  • true: require it from every caller.
  • false: never require it.

Because the policy lives in secretspec.toml and is enforced by SecretSpec, it applies to everyone and every CI runner, and is inherited through extends. Coding agents are spotted by the detect-coding-agent crate (Claude Code, Cursor, Codex, Gemini CLI, Copilot, and more); set SECRETSPEC_AGENT for a harness it doesn’t recognize.

Terminal window
cargo install secretspec

Remember the new default: agents must pass a reason: set require_reason = false to opt out.

Questions or feedback? Join us on Discord.