Skip to content

Latest commit

 

History

History
187 lines (149 loc) · 8.9 KB

architecture.md

File metadata and controls

187 lines (149 loc) · 8.9 KB

Locksmith Architecture

Overview

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

Components

Daemon

  • Listens on a Unix socket (~/.config/locksmith/locksmith.sock)
  • Exposes LocksmithService gRPC service to CLI clients
  • Manages session lifecycle and secret caching
  • Launches vault plugins on startup; kills them on shutdown

Session Manager (internal/session)

  • 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)

Config Hot-Reload

The daemon supports three reload triggers - all invoke the same Daemon.Reload() path:

  1. SIGHUP - kill -HUP <daemon-pid>
  2. locksmith reload CLI - connects to the Unix socket and calls the ReloadConfig gRPC method
  3. File watcher - fsnotify watches ~/.config/locksmith/ and fires after 1 second of quiet following a change to config.yaml

Daemon.Reload() flow:

  1. Acquire reloadMu (serializes concurrent triggers)
  2. Parse and validate the new config; abort with the old config on any error
  3. Delta-sync plugin processes: launch new vault types, kill removed ones
  4. 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.

Plugin Manager (internal/plugin)

  • 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.

Vault Plugins

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 accessible
  • Info - returns plugin name, version, and supported platforms

Bundled plugins and pinentry

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.

CLI

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.

MCP wrapper (internal/mcp)

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 via bufio.Reader.ReadBytes('\n'). On the first non-empty line it resolves the configured env mappings via GetSecret, spawns the subprocess with the resolved values, then runs as a stdio shim: PumpStdio writes 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 a HeaderResolver closure that resolves the templated headers on demand. The transport sends the first request with static headers only; if the server responds 401 or 403, 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 (sticky authenticated state). Servers that accept the MCP initialize handshake 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.

Session Delegation

Sub-agents inherit the parent session by receiving LOCKSMITH_SESSION as an environment variable. The daemon validates the session token on every request.

Security Properties

  • Secrets live only in daemon process memory, never on disk
  • Unix socket has 0600 permissions (owner-only)
  • Plugin processes are isolated; a compromised plugin cannot access other vaults
  • locksmith get without a valid session returns an error, not a leaked secret
  • Session IDs are masked in daemon log output unless logging.level: debug is active (see Debug Logging Security Notice)

Agent Integration

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.