Locksmith uses a plugin architecture: vault providers run as isolated processes communicating with the central daemon over gRPC, using hashicorp/go-plugin.
locksmith CLI ──(gRPC/Unix socket)──▶ locksmith daemon
│
┌───────────────┼───────────────┐
gRPC ▼ gRPC ▼ gRPC ▼
keychain gopass 1password
plugin plugin plugin
- Listens on a Unix socket (
~/.config/locksmith/locksmith.sock) - Exposes
LocksmithServicegRPC service to CLI clients - Manages session lifecycle and secret caching
- Launches vault plugins on startup; kills them on shutdown
- Sessions identified by
ls_followed by 64 hex chars (32 random bytes) - TTL-based expiry (default 3h, configurable)
- Per-session secret cache: secrets are fetched from vault once per session, then served from memory cache for subsequent calls
- On session invalidation: explicit byte-zeroing of cached secrets (
memclr)
The daemon supports three reload triggers - all invoke the same Daemon.Reload() path:
- SIGHUP -
kill -HUP <daemon-pid> locksmith reloadCLI - connects to the Unix socket and calls theReloadConfiggRPC method- File watcher -
fsnotifywatches~/.config/locksmith/and fires after 1 second of quiet following a change toconfig.yaml
Daemon.Reload() flow:
- Acquire
reloadMu(serializes concurrent triggers) - Parse and validate the new config; abort with the old config on any error
- Delta-sync plugin processes: launch new vault types, kill removed ones
- Atomically replace the active config via
atomic.Pointer[config.Config]
Each gRPC handler calls cfgFn() once at the start to get a consistent snapshot for
its entire execution. In-flight requests see either the old or the new config entirely -
never a mix.
- Discovers
locksmith-plugin-*binaries in standard search paths - Launches each required plugin as a child process via hashicorp/go-plugin
- Plugin processes communicate over gRPC; isolated from daemon memory space
After a plugin is dispensed, the daemon calls Info() (5-second timeout) and runs
CompatValidator against the response. Warnings (platform mismatch, version range
violation, unreachable Info(), missing or invalid version metadata) are stored
alongside the running plugin and surfaced via locksmith vault health. No plugin is
blocked from running based on these checks.
Each plugin is a standalone binary implementing the VaultProviderService gRPC service:
GetSecret- fetches a secret; triggers vault authorization (Keychain, passphrase)HealthCheck- verifies the vault is installed and accessibleInfo- returns plugin name, version, and supported platforms
Default vault plugins (keychain, gopass, 1password) and
locksmith-pinentry ship embedded in the locksmith binary as a
per-platform zip and are extracted at locksmith init to
~/.config/locksmith/. Plugin version is locked to the host
locksmith version. The full pipeline is documented in
docs/plugins/architecture.md.
Thin gRPC client to the daemon. If LOCKSMITH_SESSION is set in the environment,
locksmith get uses that session. If unset, it auto-starts a session using the
default TTL from config and prints the session ID to stderr so the caller can
optionally export it for reuse. Returns an error with a helpful hint if the daemon
is not running.
locksmith mcp run is the integration surface for AI agents that
speak the Model Context Protocol. It dials the daemon, ensures a
session, and then defers all vault-touching work until the AI client
sends its first MCP request:
- Local mode (
-- command args...): locksmith reads stdin viabufio.Reader.ReadBytes('\n'). On the first non-empty line it resolves the configured env mappings viaGetSecret, spawns the subprocess with the resolved values, then runs as a stdio shim:PumpStdiowrites the buffered first line into the child's stdin, copies the rest of the parent's stdin to the child, and copies the child's stdout back to the parent. Locksmith remains the child's parent until exit (process tree: client -> locksmith -> child, ~5-10 MB residency overhead). - Proxy mode (
--url URL): same first-line gate. On the first non-empty line locksmith creates the Transport with its static headers (those whose value template contains no vault reference) and aHeaderResolverclosure that resolves the templated headers on demand. The transport sends the first request with static headers only; if the server responds401or403, the transport invokes the resolver, caches the resulting auth headers under a mutex, and retries the request once. Subsequent requests reuse the cached headers for the lifetime of the connection (stickyauthenticatedstate). Servers that accept the MCPinitializehandshake without auth therefore never trigger a vault prompt for that connection.
The same resolveOnce lifecycle also triggers on body-level errors:
while the resolver has not yet been invoked, the proxy run loop
inspects each server response. If the response is a JSON-RPC envelope
with a non-empty top-level error field, or a result.isError: true
tool error, the proxy treats it as an auth-failure signal: it calls
resolveOnce, re-sends the original request bytes (keyed by JSON-RPC
id), and waits for the response with the same id before forwarding
to the client. Any other responses that arrive during the retry wait
are forwarded as-is. After the first attempt (success or failure), no
further body inspection happens; subsequent errors propagate verbatim.
Free-form text inside result.content[].text is never matched against
keywords - only structural signals (error, isError) trigger the
retry.
Each mode resolves its secrets exactly once - lazily, but not repeatedly. Subsequent client requests reuse the env vars or HTTP headers established on the first message.
GRPCFetcher recovers from session expiry. If a lazy fetch lands
after the original LOCKSMITH_SESSION has expired, GetSecret
returns an "invalid session" error; the fetcher invokes a
RefreshSession closure (provided by the CLI, backed by
SessionStart), updates its stored session ID under a mutex with a
compare-and-swap to avoid double refreshes, and retries the request
once. A second failure is propagated to the caller.
Auth-deferral applies to StreamableHTTP and SSETransport
identically: both retry the failed request once with resolved auth
on 401/403. AutoTransport is unaware of auth - it delegates
to its inner transport. There is one documented limitation: when
auth is resolved late while a long-lived SSE GET stream is already
open without auth, the GET is not reopened. Operators of servers
that authorise the GET but filter notifications on auth should set
--transport http.
In local mode locksmith installs signal.Notify for SIGTERM and
SIGINT while the child is running and forwards each received signal
to the child via ForwardSignals. SIGHUP and other signals are not
forwarded.
Diagnostics flow through the shared zerolog instance at the level
configured by logging.level. Output is forced to stderr so it
cannot corrupt MCP JSON-RPC on stdout; URLs in log lines and error
messages are passed through url.URL.Redacted() to mask any
credentials accidentally embedded in userinfo.
Sub-agents inherit the parent session by receiving LOCKSMITH_SESSION as an
environment variable. The daemon validates the session token on every request.
- Secrets live only in daemon process memory, never on disk
- Unix socket has
0600permissions (owner-only) - Plugin processes are isolated; a compromised plugin cannot access other vaults
locksmith getwithout a valid session returns an error, not a leaked secret- Session IDs are masked in daemon log output unless
logging.level: debugis active (see Debug Logging Security Notice)
Agents interact with the daemon exclusively through the CLI. Session management
follows the protocol described in Agent Integration:
the locksmith session ensure command reuses an existing valid session from
LOCKSMITH_SESSION or starts a new one. Platform hook templates are embedded
in the binary and installed by locksmith init (autostart prompt); the root
AGENTS.md and Agent Integration
describe the protocol for platforms without hook support.