A local macOS agent that fetches secrets on demand and serves them to a CLI over
a per-user UNIX socket. One binary, two modes (daemon + CLI). Pluggable secret
sources: 1Password via the op CLI, and the macOS login Keychain via security.
Production secrets are never cached (see Caching policy below). Built std-only
plus libc (no heavy crates, for a small, auditable supply chain).
macOS (Apple Silicon) only for now. Linux is deliberately deferred.
Primary adversary: a malicious process running as your own user (a poisoned
npm postinstall, a compromised editor extension, curl | bash). On a
single-user Mac that is the realistic attacker.
The defenses against that adversary:
- Mutual cdhash-pin at the socket. Each connection is authenticated at the
kernel boundary via its audit token (
LOCAL_PEERTOKEN): only a peer whose code-directory hash equals our own (csopsCS_OPS_CDHASH) is served, and the CLI verifies the daemon the same way (so a process that squats the socket cannot feed it a forged secret). A different binary cannot impersonate either side. - TOCTOU recheck. macOS resolves the peer via the socket's mutable
last_pid, not a connect-time snapshot, so the daemon reads the audit token once before and once after the request frame and rejects unless the principal (pid + exec generation) is identical. A single check would be a race window. - The 1Password biometric prompt. cdhash-pin does not stop the attacker from running our real CLI. The wall against that is Touch ID (delegated to the 1Password desktop app). The daemon does not cache production secrets because a same-uid process could pre-start a permissive daemon otherwise.
- Hardened runtime (when signed, see below) blocks
DYLD_INSERT_LIBRARIESinjection into a process carrying our cdhash, and the get-task-allow check rejects debuggable peers. Without hardened runtime the daemon refuses to start unlessSAGENT_INSECURE_DEV=1is set. That mode registers no secret sources, servesstatus/flushonly, and deniesget/get-many.
Out of scope: a root attacker (or SIP disabled).
task_for_pid reads our memory directly; no user-space defense helps. The
in-memory hardening (zeroize, mlock) only narrows the forensic surface
(swap/coredump); it is not a boundary against a privileged attacker.
make install # build --release + ad-hoc hardened-sign + copy to ~/.local/bin
make install-agent # + load the de.sagent.daemon LaunchAgent (RunAtLoad/KeepAlive)make install signs with the hardened runtime (ad-hoc -, no Apple Developer
account needed) so the real same-uid boundary is active. Use the installed
~/.local/bin/sagent as your CLI: it shares its cdhash with the daemon it spawns
(or the launchd-run one), which is what the mutual auth checks. make install-agent additionally runs the daemon under launchd; otherwise the first
CLI call auto-spawns it.
A plain cargo run -- daemon is rejected by default because it lacks the
hardened runtime. For development without secret access (status and flush
only), set SAGENT_INSECURE_DEV=1; that mode cannot fetch real secrets.
Requires the 1Password 8 desktop app installed, running, unlocked, with the
"Connect with 1Password CLI" integration enabled. The daemon spawns op with a
scrubbed environment, so it relies on the desktop-app integration, not a shell
op signin session token.
sagent get op://Private/GitHub/token # fetch a secret to stdout (no newline)
sagent get - <<'EOF'
op://Private/GitHub/token
EOF
sagent get keychain://github-token/alice # fetch from the macOS Keychain
sagent get-many - <<'EOF'
keychain://a
keychain://b
EOF
sagent run -- npm start # resolve ./.env references, exec with them in env
sagent run --env-file prod.env -- ./serve
sagent exec-credential --token op://Work/k8s-prod/token # kubectl credential plugin
sagent status # cache + source metadata (no secrets)
sagent flush # evict cached entries (shipped sources cache nothing)
sagent daemon [--foreground] # run the custodian (normally auto-spawned)Pass - to read the reference, or the newline-separated get-many request, from
stdin. This keeps reference names out of the CLI process argv. Literal argv
references remain supported for convenience, but same-uid processes can inspect
argv while a source fetch is blocked on biometrics.
get writes the raw value with no trailing newline; get-many prints one
reference<TAB>hex(value) line per entry (hex, so binary values and embedded
newlines survive line-oriented parsing).
status prints metadata only, e.g. entries=0, sources=2,
hardened=true|false, insecure_dev=true|false. The daemon auto-spawns on
first use, elects a single instance via flock, and binds a 0600 socket inside a
0700 per-user runtime dir (<_CS_DARWIN_USER_TEMP_DIR>/sagent).
sagent run feeds dotenv-style applications without a plaintext .env on
disk. The template (default ./.env, committable) holds references instead of
values; everything else stays a literal:
DATABASE_URL=postgres://localhost:5432/dev
DB_PASSWORD=op://Private/db/password
GITHUB_TOKEN=keychain://github-tokensagent run -- npm start parses the template, fetches all references in one
get-many round trip over the authenticated socket (a template without
references never contacts the daemon), merges them over the inherited
environment (template wins on conflict), and replaces itself with the command
via execve: no wrapper process stays around holding plaintext, and the
child's exit code and signals pass through untouched.
The accepted template grammar is a strict dotenv subset that fails closed:
blank lines, full-line # comments, and KEY=VALUE pairs, with optional
single or double quotes around the value (stripped, no escapes). No export
prefix, no $VAR interpolation, no inline comments, no multi-line values, no
duplicate keys. Parse errors name the line number, never the line content.
Security trade-off, stated plainly: this is the one deliberate exception to
the "no secret in a child environment" rule (.agents/rules/security.md).
The child's initial environment is readable by same-uid processes via
KERN_PROCARGS2 for as long as the child runs, and sagent run -- <cmd>
delegates trust to that command including its dependency tree (the cdhash pin
ends at the exec boundary; the biometric prompt remains the acquisition gate).
That window is strictly smaller than a plaintext .env sitting on disk, which
is what this replaces: nothing persists, nothing reaches backups, Spotlight or
an accidental git add.
kubectl has a native interface for keeping key material out of the kubeconfig:
an exec credential plugin.
sagent exec-credential implements it. The kubeconfig keeps only the cluster
data and the references; the bearer token or the client certificate plus key
(PEM) are fetched on demand and handed to kubectl as an ExecCredential JSON
object on stdout:
users:
- name: prod
user:
exec:
apiVersion: client.authentication.k8s.io/v1
command: sagent # absolute path also works: ~/.local/bin/sagent
args:
- exec-credential
- --client-cert
- op://Work/k8s-prod/cert
- --client-key
- op://Work/k8s-prod/key
interactiveMode: Never--token <ref> serves token-based clusters; --api-version v1beta1 matches
older exec blocks (the value must equal the kubeconfig's apiVersion, kubectl
rejects a mismatch). interactiveMode: Never is correct because the biometric
prompt comes from the 1Password desktop app, not from the terminal.
This path is tighter than sagent run: the secret travels over a stdout pipe
to the parent process only, which KERN_PROCARGS2 cannot read, so the
environment trade-off above does not apply. The references on the exec args
are the same accepted metadata exposure as literal references on sagent get.
No expirationTimestamp is emitted: client-go caches the credential in memory
for the lifetime of one process (a long-lived k9s asks once), and every new
kubectl process re-fetches through the daemon and the desktop-app
authorization, which matches the no-cache posture. The latency of one op
fetch per kubectl invocation is the deliberate price; a plugin-side disk token
cache like other plugins use would defeat the point.
- 1Password via
op(op://vault/item/field).oplives in a user-writable Homebrew prefix, so its identity is pinned at spawn, not via its path: it is started suspended (posix_spawnwithSTART_SUSPENDED), its code-signing Team Identifier (2BUA8C4S2C, AgileBits) and signing identifier (com.1password.op) verified withcsops, then resumed. Output from a binary that fails the identity check is never used. (The suspend is best-effort against aSIGCONTrace; the identity gate is the real guarantee, see Known limitations.) - macOS Keychain via
/usr/bin/security(keychain://service[/account], account optional).securityis SIP-protected, so path-pinning suffices. Read withsecurity -g, whose output is parsed unambiguously: a quoted printable value or a0x<HEX>form that is hex-decoded, so non-ASCII / binary secrets round-trip byte-exact (plain-wwould silently hex-encode them). Keychain reads are never cached becausesecurity -gdoes not enforce user presence per read.
Both sources scrub the child environment, return the value only over a pipe
(stdout for op, stderr for security -g; never on argv or in env), cap
captured output, run in their own process group with a timeout, and reject a
reference that could inject an argument. Reference names
are sensitive metadata: they are never written to daemon logs.
The daemon does not cache production secrets. Every get and get-many is a
cold source fetch, so 1Password re-prompts through the desktop app and Keychain
is read again through /usr/bin/security. The cache implementation stays in the
tree for a future source that opts into caching via its Lease; the shipped
sources always hand back no-cache leases, so status reports entries=0 and
flush has nothing to evict.
On top of the same-uid auth boundary, the daemon hardens at startup:
setrlimit(RLIMIT_CORE, 0) (no coredump leak), ptrace(PT_DENY_ATTACH) (no
casual lldb), and best-effort mlock on every SecretBytes page (no swap; a
failed lock is logged, not fatal).
SecretBytes is wiped on drop with volatile writes plus a black_box barrier,
has no Clone and a redacted Debug/Display, and the release profile keeps
panic = "unwind" so the wipe runs on a panic. These narrow the swap/coredump
forensic surface; they are not a boundary against a privileged attacker.
Per-connection read/write timeouts and a concurrent-handler cap keep a stalled or
flooding same-uid peer from pinning threads/fds; a hung op/security is reaped
by killing its process group at the deadline; captured output is byte-bounded.
Implement SecretSource (src/source.rs): name, validate(reference),
fetch_capped(reference, cap) -> (SecretBytes, Lease), and register it in
Daemon::new.
Contract: never log the value, never put the value on argv, never place it in a
child environment, never log the reference name, bound the output, and bring your
own argument-injection guard for the reference format. See
.agents/rules/security.md.
cargo test # unit + integration tests
cargo clippy --all-targets -- -D warnings
cargo fmt -- --checkTests need no vault or biometric prompt: op --version exercises the full
suspend, verify, resume path, /bin/sh exercises the reject path (and asserts
the rejected binary never ran), and a foreign-cdhash client connecting to the
socket is asserted denied. The live op read and non-ASCII Keychain paths were
validated by hand end-to-end.
- Drop does not run on SIGKILL. Zeroize is best-effort; a killed daemon leaves secrets in memory until the pages are reclaimed (unswapped if mlock'd).
read_to_endmay leave stale copies. A secret that grows a buffer via reallocation can leave un-wiped copies in freed heap beforeSecretBytestakes ownership. The final buffer is wiped; the intermediate growth is not.- The op suspend is best-effort. A same-uid attacker could race
SIGCONTto run a replacement binary, but its output is still rejected (team-id + identifier mismatch), so it gains nothing. - mlock is hardening, not a boundary. It blocks swap/coredump forensics, not a privileged attacker who can read the address space.
- Stale daemon after a rebuild. A daemon from an older build has a different
cdhash, so the new CLI (correctly) refuses it and cannot replace it while it
holds the flock. For an installed LaunchAgent, run
make install-agent; it bootouts and bootstraps the label deterministically. For a manual foreground dev daemon, stop that terminal process directly or usemake uninstallfor the installed agent.
An alias registry, a 1Password service-account / Connect source for headless use, and Linux support.