Skip to content

derveloper/sagent

Repository files navigation

sagent

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.

Threat model

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 (csops CS_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_LIBRARIES injection into a process carrying our cdhash, and the get-task-allow check rejects debuggable peers. Without hardened runtime the daemon refuses to start unless SAGENT_INSECURE_DEV=1 is set. That mode registers no secret sources, serves status/flush only, and denies get/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.

Install

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.

Usage

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

Env file templates (sagent run)

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-token

sagent 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 credentials (sagent exec-credential)

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.

Sources

  • 1Password via op (op://vault/item/field). op lives in a user-writable Homebrew prefix, so its identity is pinned at spawn, not via its path: it is started suspended (posix_spawn with START_SUSPENDED), its code-signing Team Identifier (2BUA8C4S2C, AgileBits) and signing identifier (com.1password.op) verified with csops, then resumed. Output from a binary that fails the identity check is never used. (The suspend is best-effort against a SIGCONT race; the identity gate is the real guarantee, see Known limitations.)
  • macOS Keychain via /usr/bin/security (keychain://service[/account], account optional). security is SIP-protected, so path-pinning suffices. Read with security -g, whose output is parsed unambiguously: a quoted printable value or a 0x<HEX> form that is hex-decoded, so non-ASCII / binary secrets round-trip byte-exact (plain -w would silently hex-encode them). Keychain reads are never cached because security -g does 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.

Caching policy

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.

Hardening

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.

Robustness

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.

Adding a secret source

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.

Testing

cargo test                                   # unit + integration tests
cargo clippy --all-targets -- -D warnings
cargo fmt -- --check

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

Known limitations

  • 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_end may leave stale copies. A secret that grows a buffer via reallocation can leave un-wiped copies in freed heap before SecretBytes takes ownership. The final buffer is wiped; the intermediate growth is not.
  • The op suspend is best-effort. A same-uid attacker could race SIGCONT to 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 use make uninstall for the installed agent.

Not yet

An alias registry, a 1Password service-account / Connect source for headless use, and Linux support.

About

A local macOS daemon that caches secrets in memory and serves them to a CLI over a per-user UNIX socket with cdhash-pinned mutual auth.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors