ov agent
ov agent is a long-running daemon that holds a scope-derived keyring in memory after a one-time authentication — the raw KEK is used only inside the unlock window and zeroized once the keyring is derived — then serves secret injection requests over a Unix socket. It has two clients today: the MCP bridge (ov mcp serve connects to the agent for all crypto operations) and ov run, which dials the same socket to run commands with per-request secrets and zero password prompts once the agent is unlocked (see ov run for the full agent-first flow, its divergences from the local path, and the Ctrl-C caveat). If the agent isn’t running when a client starts (or, for ov run, isn’t reachable at all), MCP tool calls fail with a plain-text ov agent is not running error while ov run silently falls back to its own password prompt / --machine-key path; if a running agent’s session locks or expires, both clients see SESSION_LOCKED (see Troubleshooting for both, including when the MCP client needs a restart).
On a workstation, ov agent start with no flags prompts for your master password once and keeps the session unlocked — it runs in the foreground, so keep the terminal open. On Linux servers and CI runners, pair it with a machine key so ov run needs no prompt. Note that the MCP bridge can only authenticate to an interactively started agent today — a machine-key agent derives its socket key from the KEK and writes no key file for the bridge to read.
How it works
Section titled “How it works”ov agent start --machine-key /etc/ov/machine.kek ↓daemon starts, derives session keyring (raw KEK zeroized), listens on Unix socket ↓ov run --machine-key /etc/ov/machine.kek --secrets DATABASE_URL -- ./migrate ↓run finds the socket automatically, sends a signed request →agent decrypts → subprocess runs on the daemon sideThe agent authenticates requests via HMAC-SHA256 derived from the machine key. Only a client that holds the same machine key can send valid requests. The socket itself is protected by 0700 directory + 0600 permissions.
Plaintext secret values never cross the socket — the agent runs the subprocess itself and returns only the exit code.
Each request declares its own (app, env) pair and the daemon resolves it per request (with a short-lived cache); --app/--env are only the defaults for requests that don’t declare one. If a request names an environment that doesn’t exist in its app, the daemon refuses with the ENV_NOT_FOUND wire code. The startup pair is validated once at start and is not re-validated per request.
Version gate for ov run: the daemon reports a version string (currently ov-agent/5) on ov agent status. ov run checks this before dispatching a request — it requires at least ov-agent/2 (the version where per-request env routing shipped) and falls back to its local password-prompt path against anything older, rather than risk silently sending a per-request env to a daemon that would ignore it and serve its startup env instead. ov-agent/5 additionally advertises the activity feed. Restart the agent after upgrading ov to pick up the new version.
Prerequisites
Section titled “Prerequisites”A machine key file is required to start the agent:
ov auth derive-machine-key --out /etc/ov/machine.kekchmod 600 /etc/ov/machine.kekSee Machine key setup for details.
Commands
Section titled “Commands”| Command | Description |
|---|---|
ov agent start |
Start the agent daemon |
ov agent stop |
Stop the running agent |
ov agent status |
Show agent status (--json for scripts) |
ov agent activity |
Show the daemon’s recent-activity feed |
ov agent provision |
Create (or remove) ~/.ov/kek.enc for in-place re-unlock |
ov agent install |
Install a systemd unit file |
ov agent start
Section titled “ov agent start”ov agent start --machine-key /etc/ov/machine.kek --app my-saasStarts the daemon in the foreground. Use --foreground with systemd Type=simple.
| Flag | Description |
|---|---|
--machine-key PATH |
Path to machine key file (required for HMAC socket auth) |
--app NAME |
Default app slug for all secret ops; clients may override per request — as of v0.12.0 (OV-319) every MCP tool (not just vault_run) sends its own resolved app per call |
--env NAME |
Default environment slug for all secret ops; clients may override per request (resolved via flags → .ov.yaml → config → production) — as of v0.12.0 (OV-319) every MCP tool sends its own resolved env per call, including the three write tools, which additionally require declared expected_app/expected_env under OV_STRICT_CONTEXT (default on) |
--socket PATH |
Unix socket path (default: $XDG_RUNTIME_DIR/ov-agent/ov-agent.sock) |
--pid-file PATH |
Path to write PID file |
--foreground |
Run in foreground (required for systemd Type=simple) |
ov agent stop
Section titled “ov agent stop”ov agent stopov agent stop --pid-file /run/ov-agent/ov-agent.pidSends SIGTERM to the agent and returns immediately. SIGTERM cancels the daemon’s own context, which propagates into any in-flight subprocess and kills it abruptly — the daemon does not wait for running commands to finish before exiting. To kill a single orphaned command while keeping the daemon (and its unlocked session) alive, see the Ctrl-C caution in ov run.
ov agent status
Section titled “ov agent status”ov agent statusov agent status --machine-key /etc/ov/machine.kekQueries the agent socket and prints uptime and version. If the socket is unreachable, falls back to checking the PID file.
Pass --machine-key if the agent was started with one — without it, the signed request will be rejected and status falls back to the PID file check.
ov agent status --jsonEmits one JSON object for scripts and desktop shells. The state field is a closed six-state enum:
state |
Meaning |
|---|---|
unlocked |
Daemon up, session unlocked and serving |
locked |
Daemon up, but the KEK has been relocked (idle/lifetime timer fired) — restart the session to unlock |
auth_rejected |
Daemon reachable but it rejected the signed status request (wrong or missing socket key — pass --machine-key if the agent was started with one) |
error |
Daemon reachable but returned an unrecognized reply |
socket_unreachable_pid_alive |
Socket didn’t answer but the PID-file process is alive (starting up, wedged, or a stale socket path) |
not_running |
No socket and no live PID |
The command always exits 0 — the state field, not the process exit code, carries the result, so scripts branch on JSON rather than exit codes. running is a convenience bool (true for unlocked/locked). In the unlocked state the object also carries uptime, version, app_slug/env_slug, session_age_seconds, and the timer countdowns; socket_unreachable_pid_alive carries the pid from the PID file; and the locked reply still carries version, so a caller can gate feature availability (e.g. the activity feed needs ov-agent/5) without a second probe.
The unlocked reply also carries two additive fields set by ov agent provision (below): kek_provisioned (bool — whether ~/.ov/kek.enc exists) and unlock_posture ("file" / "none" today — "keychain" joins the enum once the macOS keychain backend ships). On every other state the fields are absent, which is not the same as false — a script gating on kek_provisioned must treat a missing field as “unknown, check again when unlocked”, never as “not provisioned”. kek_provisioned may also be absent on an unlocked reply if the daemon could not perform the check at all (for example, no resolvable home directory). Both are advisory display fields, not a security attestation — a same-UID actor could plant a presence-passing artifact to flip them.
ov agent provision
Section titled “ov agent provision”ov agent provisionov agent provision --forceov agent provision --removeov agent provision writes ~/.ov/kek.enc, an in-place unlock file that lets the agent daemon relock on its normal schedule and re-unlock with one password entry instead of a full restart. The daemon-side unlock that consumes this file ships separately (unlock v2) — until then, a provisioned kek.enc sits on disk unused; it’s a harmless cache, and this command’s only job is to create (or remove) it.
The file is wrapped twice: once under a key derived from your master password (Argon2id), once under a machine-local wrap key — a file-based key at ~/.ov/wrap.key. That wrap key will use the OS keychain once the macOS keychain backend ships; today --backend file is the macOS option too, with the same reduced file posture as Linux. Both layers are required to recover your vault key. Before writing anything, ov agent provision re-validates your master password against a real secret in your vault — a mistyped password is refused before anything touches disk — and refuses outright on an empty vault, where that validation would have nothing to check against (“vault is empty — create your first secret, then provision”).
It requires an interactive terminal and there is no scripted or non-interactive form: --yes skips the confirmation prompts below, never the password prompt or the TTY requirement itself.
Before prompting for your password, ov agent provision prints five consent statements and asks you to confirm all of them at once:
- What will exist on disk — “your vault key, encrypted twice (your password via Argon2id + a machine-local key), will be stored at ~/.ov/kek.enc”
- What it enables — “the agent will relock on its normal schedule and re-unlock with one password entry instead of a restart”
- The honest security posture (file backend) — “anyone with your user account and your password can open it; if this disk is stolen unencrypted, its protection is your password’s strength — use full-disk encryption”
- Forgot-password is unchanged — “if you forget your master password, your vault is permanently unrecoverable — provisioning does not change this”
- It’s reversible any time — “you can remove or re-create this file at any time: ov agent provision –remove”
Statement 3 is the one to internalize. On the file backend (the default on Linux, and everywhere --backend keychain isn’t available), the machine-local wrap key sits on the same disk as kek.enc itself — so the outer wrap adds no protection against a stolen, powered-off drive. At-rest resistance there is your password’s entropy run through Argon2id, the same bound as an attacker who already has your live user session. If you provision on a laptop or any machine that could be physically stolen, use full-disk encryption — it’s the compensating control, not ov agent provision itself. The macOS keychain backend is designed to restore the outer wrap’s stolen-disk value (biometric/passcode-gated); it isn’t implemented yet, so it refuses cleanly with a pointer at --backend file in the meantime.
| Flag | Description |
|---|---|
--backend file|keychain |
Wrap-key backend. Defaults to keychain on macOS, file everywhere else. The keychain backend isn’t implemented yet — it fails closed with a pointer at --backend file rather than silently falling back |
--force |
Replace an existing kek.enc. Prints one combined consent + replacement confirmation (“this replaces the existing kek.enc; the old one becomes useless; continue?”) before doing anything destructive, then runs the full provisioning flow |
--remove |
Delete kek.enc and the wrap key. No lock, no password, no prompts — this is rm-equivalent — and returns the agent to prompting for your master password at each start |
--yes |
Skip the consent/confirmation prompts. Still requires a TTY — not a scripted lane |
ov agent activity
Section titled “ov agent activity”ov agent activityov agent activity --jsonov agent activity --machine-key /etc/ov/machine.kekPrints the daemon’s in-memory activity feed — the last 100 connection outcomes, newest first, as a TS / OP / APP/ENV / TARGET / OUTCOME / ORIGIN table. Metadata only: op names, app/env slugs, secret names, outcomes, and origins — never command text, arguments, output, or secret values (the ring is structurally incapable of holding a value). The feed lives only in daemon memory: it never touches disk or leaves the machine, and it empties on daemon restart.
For run rows, TARGET shows the secret names the command requested (joined, truncated with … past 64 characters — the full list is always in --json); for secret CRUD rows it shows the target secret’s name. APP/ENV shows the context the request declared; a - means it used the daemon’s default context. Cells with nothing to show render -.
TS OP APP/ENV TARGET OUTCOME ORIGIN2026-08-15T10:00:00Z run acme-web/prod DATABASE_URL,STRIPE_KEY exit:0 mcp2026-08-15T09:59:00Z reject - - auth-failed -2026-08-15T09:58:00Z run acme-web/staging DB_URL SCOPE_DENIED cliUseful for answering “what has my AI agent been doing with the vault?” at a glance — every vault_run, secret mutation, scope denial, and authentication reject lands here. OUTCOME is ok, exit:<n> (a run’s exit status), an error code like SESSION_LOCKED or SCOPE_DENIED, or error (the feed deliberately never carries error prose). Pre-auth rejects appear as reject rows carrying only a timestamp and category (the daemon records nothing an unauthenticated caller controls). The six categories:
| Category | Meaning |
|---|---|
peercred |
A different UID (or a peer the kernel couldn’t identify) dialed the socket — the daemon refuses cross-user callers outright |
auth-failed |
Same UID, but the request’s signature didn’t verify — usually a stale or wrong socket key (or --machine-key mismatch) |
bad-json |
The request didn’t parse — typically an incompatible or misbehaving client |
read-error |
The connection died or timed out before a full request arrived |
too-many-secrets |
The request exceeded the per-request secret-name bound |
unknown-op |
A valid, authenticated-format request naming an op this daemon doesn’t know — usually version skew between client and daemon |
Occasional read-error or bad-json entries are noise-grade; a stream of peercred or auth-failed rows means something on the machine is probing the socket without the right identity, which is worth understanding.
Reading the feed is keepalive-safe: like ov agent status, it never resets the idle-relock timer, so a dashboard polling this endpoint can’t keep the vault unlocked. It also answers while the session is locked — the feed needs no vault access. The honest flip side: any process running as your user that holds the socket key can read this feed too, including while the vault is locked — it discloses historical secret names (never values) at times when list-secrets would answer SESSION_LOCKED. That visibility is the feature’s purpose; point dashboards at it knowing the audience is “everything running as you.”
--json emits the raw ring for machine consumers. Note the table view sanitizes terminal-escape sequences out of free-text fields (target and secret names come from whatever you ran ov run against); the JSON view is raw by design — treat it as data, not as something to cat to a terminal.
| Flag | Description |
|---|---|
--socket PATH |
Unix socket path |
--machine-key PATH |
Path to machine key file (required when the agent was started with one) |
--json |
Emit activity events as JSON (raw, unsanitized — machine consumers only) |
ov agent install
Section titled “ov agent install”ov agent install --machine-key /etc/ov/machine.kek --app my-saas --env productionov agent install --machine-key /etc/ov/machine.kek --app my-saas --env production --enableWrites a systemd unit file and prints the commands to enable and start it. Pass --enable to run systemctl daemon-reload && systemctl enable --now ov-agent automatically.
--app and --env are required — they are routing flags baked into the unit’s ExecStart so the agent isn’t coupled to the service account’s config-file state. They do not scope the key.
| Flag | Description |
|---|---|
--machine-key PATH |
Path to machine key file (required) |
--app SLUG |
App slug baked into the unit’s ExecStart (required) |
--env SLUG |
Env slug baked into the unit’s ExecStart (required) |
--socket PATH |
Unix socket path |
--unit-name NAME |
systemd unit name (default: ov-agent) |
--user NAME |
systemd User= field (default: $SUDO_USER if present, else current user; cannot be root) |
--enable |
Run systemctl to enable and start immediately |
systemd setup
Section titled “systemd setup”The recommended production setup:
# 1. Create the machine keyov auth derive-machine-key --out /etc/ov/machine.kekchmod 600 /etc/ov/machine.kek
# 2. Install the systemd unitov agent install \ --machine-key /etc/ov/machine.kek \ --app my-saas --env production \ --enableThe generated unit file uses Type=simple and restarts on failure. It runs as the current user.
Security model
Section titled “Security model”- Socket directory:
0700(only the owning user can access it) - Socket file:
0600 - HMAC-SHA256 per-request authentication when started with
--machine-key - Replay window: ±30 seconds (timestamp-based; no stateful nonce store)
- Plaintext values never cross the socket — the agent injects them directly into subprocess environments
- Per-request
(app, env)routing: the daemon serves the environment each request declares, never silently substituting the start-time default; a request naming a nonexistent environment fails closed withENV_NOT_FOUND. Environment selection is routing, not authorization — every env is reachable with the one unlocked key (an operator env allowlist is tracked as OV-320)
Known limitations:
- No server-side nonce store: a captured signed request can be replayed within the 30-second validity window
- Interactive sessions (no
--machine-key) have no socket authentication beyond filesystem permissions
Related
Section titled “Related”- Machine key setup — generating and managing machine keys
- ov run — uses a running agent automatically (zero prompts); falls back to its own no-daemon path when one isn’t reachable
- CI/CD integration — using OpaqueVault in automated pipelines