Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Background agent (`pass-cli agent`)** (#116) — an optional daemon that unlocks the vault once and holds it in memory, answering read-only credential lookups over a local unix socket so `exec`/`export`/`inject` need no master-password prompt and no key derivation on each call. It serves resolved field **values only** — the master password and derived key never cross the socket. Auto-locks after `--idle` inactivity (default 15m) and always after `--max-ttl` (default 8h), and locks + exits on SIGINT/SIGTERM. **`pass-cli agent start`** backgrounds the agent (unlock once on your terminal, then detach — no shell `&` needed); **`pass-cli agent serve`** (or bare `pass-cli agent`) runs it in the foreground. **`agent lock`** / the top-level **`pass-cli lock`** / **`agent stop`** all zero the resident secrets and stop the agent promptly (freeing the socket so the next command falls back to direct-open; re-run `agent start` to re-establish it); `agent status` reports its state (never prints secrets). When no agent is running, every command **transparently falls back** to opening and unlocking the vault directly, so the agent is a pure optimization, never a dependency. Socket path: `$PASS_CLI_AGENT_SOCK`, else `$XDG_RUNTIME_DIR/pass-cli/agent.sock`, else `~/.pass-cli/agent.sock` (directory `0700`, socket `0600`). Connections are additionally authorized by peer credential — only a process owned by the same user may talk to the agent, and any failure to read the credential is a rejection (fail-closed): Linux via `SO_PEERCRED`, macOS via `getsockopt(LOCAL_PEERCRED)`. (Windows would use a named pipe + ACL; the Windows agent is not yet implemented and falls back to direct-open.) POSIX only for now; a Windows named-pipe transport is planned.
- **Background agent (`pass-cli agent`)** (#116) — an optional daemon that unlocks the vault once and holds it in memory, answering read-only credential lookups over a local unix socket so `exec`/`export`/`inject` need no master-password prompt and no key derivation on each call. It serves resolved field **values only** — the master password and derived key never cross the socket. Auto-locks after `--idle` inactivity (default 15m) and always after `--max-ttl` (default 8h), and locks + exits on SIGINT/SIGTERM. **`pass-cli agent start`** backgrounds the agent (unlock once on your terminal, then detach — no shell `&` needed); **`pass-cli agent serve`** (or bare `pass-cli agent`) runs it in the foreground. **`agent stop`** zeroes the resident secrets and stops the agent (freeing the socket so the next command falls back to direct-open; re-run `agent start` to re-establish it); `agent status` reports its state (never prints secrets). When no agent is running, every command **transparently falls back** to opening and unlocking the vault directly, so the agent is a pure optimization, never a dependency. Socket path: `$PASS_CLI_AGENT_SOCK`, else `$XDG_RUNTIME_DIR/pass-cli/agent.sock`, else `~/.pass-cli/agent.sock` (directory `0700`, socket `0600`). Connections are additionally authorized by peer credential — only a process owned by the same user may talk to the agent, and any failure to read the credential is a rejection (fail-closed): Linux via `SO_PEERCRED`, macOS via `getsockopt(LOCAL_PEERCRED)`. (Windows would use a named pipe + ACL; the Windows agent is not yet implemented and falls back to direct-open.) POSIX only for now; a Windows named-pipe transport is planned.
- **`export` command — print shell statements that set credentials as env vars** (#115) — `pass-cli export` emits `export NAME='value'` for `eval`/`source`, the blessed replacement for `VAR="$(pass-cli get …)"`: `eval "$(pass-cli export --set GITHUB_TOKEN=github)"`. Uses the same mapping grammar as `exec` (repeatable `--set ENV_NAME=service[/field]` and the convenience form `pass-cli export <service>`, which derives the name from the service), and the same `-f/--field` selection. `--format sh|fish|powershell` selects the shell syntax (default `sh`). Read-only like `exec` (records no usage, triggers no sync push). Because `export` output is meant to be eval'd, env names are validated against `[A-Za-z_][A-Za-z0-9_]*` before the vault is opened, and every field value is shell-quoted per target shell.
- **`inject` command — render a template, substituting `${pass:service/field}` references** (#115) — `pass-cli inject` reads a template from `--in-file`/stdin and writes it back with every `${pass:service/field}` reference replaced by the credential's value: `echo 'postgres://app:${pass:db/password}@host/db' | pass-cli inject`. This is the composite/derived-secret tool (whole config files, connection strings) that a single `ENV=service` mapping cannot express. Only `${pass:...}` is special (`$VAR`/`${VAR}`/`$(...)` pass through); resolution is single-pass and fail-closed (an unknown or malformed reference errors and writes nothing). `--out-file` is created `0600`. Read-only.
- **`exec --env-file <path>`** (#115) — a third credential source for `exec`: a file of `KEY=<template>` lines whose values may embed `${pass:service/field}` references, resolved and injected into the child environment (never written to disk). Composes with `--set`; blank lines and `#` comments are ignored, and each `KEY` is validated as an env var name. This supersedes a separate `run` command (folded into `exec` to avoid duplicating its inject-then-run behavior).
Expand Down
3 changes: 1 addition & 2 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,7 @@ var agentStartCmd = &cobra.Command{
Long: `Start launches the agent as a background process and returns as soon as it is
unlocked and listening — no shell '&' needed. The one-time unlock happens on your
terminal (a prompt, or silently via the keychain); after that the agent runs
detached and survives closing the terminal. Stop it with 'pass-cli agent stop'
(or 'pass-cli lock').`,
detached and survives closing the terminal. Stop it with 'pass-cli agent stop'.`,
Args: cobra.NoArgs,
RunE: runAgentStart,
}
Expand Down
56 changes: 0 additions & 56 deletions cmd/lock.go

This file was deleted.

9 changes: 4 additions & 5 deletions docs/05-operations/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ dependency.
pass-cli agent start # unlock once, then background itself
pass-cli exec --set GITHUB_TOKEN=github -- gh repo list # resolves via the agent
pass-cli agent status # unlocked? idle? max-ttl left?
pass-cli agent lock # zero secrets and stop the agent
pass-cli agent stop # same: lock and exit
pass-cli agent stop # zero secrets and stop the agent
```

`agent start` launches the agent in the **background** and returns as soon as it is
Expand All @@ -28,9 +27,9 @@ detached and survives closing the terminal. To run it in the **foreground** inst
(e.g. under a supervisor), use `pass-cli agent serve` (or bare `pass-cli agent`) and
background it yourself with `&`.

`agent lock`, the top-level `pass-cli lock`, and `agent stop` all zero the resident
secrets and stop the agent, freeing the socket; the next command falls back to
direct-open. To bring the agent back, run `pass-cli agent start` again.
`agent stop` zeroes the resident secrets and stops the agent, freeing the socket;
the next command falls back to direct-open. To bring the agent back, run
`pass-cli agent start` again.

Flags:

Expand Down
3 changes: 0 additions & 3 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,6 @@ func (a *Agent) Handle(req Request) Response {
switch req.Method {
case MethodStatus:
return a.handleStatus()
case MethodLock:
a.lockLocked("lock")
return okResponse()
case MethodShutdown:
a.lockLocked("shutdown")
return okResponse()
Expand Down
10 changes: 5 additions & 5 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ func TestAgent_VersionMismatch(t *testing.T) {
}
}

func TestAgent_LockRefusesResolve(t *testing.T) {
func TestAgent_LockedRefusesResolve(t *testing.T) {
a := New(newTestVault(t), Options{})
if resp := a.Handle(Request{Version: ProtocolVersion, Method: MethodLock}); !resp.OK {
t.Fatalf("lock failed: %s", resp.Error)
if resp := a.Handle(Request{Version: ProtocolVersion, Method: MethodShutdown}); !resp.OK {
t.Fatalf("shutdown failed: %s", resp.Error)
}
resp := a.Handle(resolveReq(Ref{Service: "github"}))
if resp.OK {
Expand Down Expand Up @@ -154,9 +154,9 @@ func TestAgent_LoggerNeverEmitsSecret(t *testing.T) {
if !resp.OK {
t.Fatalf("resolve failed: %s", resp.Error)
}
// Trigger lock/status events too, to exercise more log paths.
// Trigger status/shutdown events too, to exercise more log paths.
a.Handle(Request{Version: ProtocolVersion, Method: MethodStatus})
a.Handle(Request{Version: ProtocolVersion, Method: MethodLock})
a.Handle(Request{Version: ProtocolVersion, Method: MethodShutdown})

logged := buf.String()
if strings.Contains(logged, testSecret) {
Expand Down
13 changes: 0 additions & 13 deletions internal/agent/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,6 @@ func Stop() error {
return nil
}

// LockAgent tells a running agent to zero its resident secrets; the agent then
// stops and frees its socket (see serve.go). Returns ErrNoAgent if none is running.
func LockAgent() error {
resp, err := sendControl(MethodLock)
if err != nil {
return err
}
if !resp.OK {
return errors.New(resp.Error)
}
return nil
}

// QueryStatus returns a running agent's status snapshot.
func QueryStatus() (*Status, error) {
resp, err := sendControl(MethodStatus)
Expand Down
3 changes: 1 addition & 2 deletions internal/agent/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ const ProtocolVersion = 1
// the key never crosses the wire. Adding one would break the core invariant.
const (
MethodResolve = "resolve" // resolve field values for a batch of mappings
MethodLock = "lock" // zero the resident secrets; the server then stops (socket freed)
MethodStatus = "status" // report unlocked/idle/ttl (never secrets)
MethodShutdown = "shutdown" // lock and signal the server to stop
MethodShutdown = "shutdown" // zero the resident secrets and stop the agent (socket freed)
)

// Ref is one credential reference in a resolve request: a service and an optional
Expand Down
10 changes: 5 additions & 5 deletions internal/agent/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ func (s *Server) handleConn(conn net.Conn) {
resp := s.agent.Handle(req)
_ = json.NewEncoder(conn).Encode(resp)

// Free the socket promptly whenever a request leaves the agent locked — an
// explicit `lock`/shutdown, or idle/max-TTL enforcement that fired during this
// request. Serve() then returns and runAgent removes the socket, so clients fall
// back to direct-open and a fresh `pass-cli agent` can rebind, instead of hitting
// a locked-but-running agent for up to one expiry tick.
// Free the socket promptly whenever a request leaves the agent locked — a
// `stop` (shutdown), or idle/max-TTL enforcement that fired during this request.
// Serve() then returns and runAgent removes the socket, so clients fall back to
// direct-open and a fresh `pass-cli agent` can rebind, instead of hitting a
// locked-but-running agent for up to one expiry tick.
if s.agent.Locked() {
s.Stop()
}
Expand Down
19 changes: 9 additions & 10 deletions internal/agent/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,30 +110,29 @@ func TestServer_ShutdownStops(t *testing.T) {
}
}

// TestServer_LockStopsServerPromptly verifies that a `lock` request stops the
// server right away (freeing the socket) rather than leaving a locked-but-running
// agent around until the next expiry tick — so clients fall back to direct-open and
// a fresh agent can rebind immediately.
func TestServer_LockStopsServerPromptly(t *testing.T) {
// TestServer_StopStopsServerPromptly verifies that a `stop` (shutdown) request
// stops the server right away (freeing the socket) so clients fall back to
// direct-open and a fresh agent can rebind immediately.
func TestServer_StopStopsServerPromptly(t *testing.T) {
stop := startTestAgent(t)
defer stop()

if _, ok := DialResolver(); !ok {
t.Fatal("agent should be reachable before lock")
t.Fatal("agent should be reachable before stop")
}

if err := LockAgent(); err != nil {
t.Fatalf("LockAgent: %v", err)
if err := Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}

// The socket must become unreachable quickly (server stopped on the lock).
// The socket must become unreachable quickly (server stopped on the shutdown).
deadline := time.Now().Add(2 * time.Second)
for {
if _, ok := DialResolver(); !ok {
return // server stopped — clients will now fall back to direct-open
}
if time.Now().After(deadline) {
t.Fatal("agent still reachable after lock — server did not stop promptly")
t.Fatal("agent still reachable after stop — server did not stop promptly")
}
time.Sleep(5 * time.Millisecond)
}
Expand Down
48 changes: 10 additions & 38 deletions test/integration/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,58 +237,30 @@ func TestIntegration_Agent_StartDaemonizes(t *testing.T) {
}
}

// TestIntegration_Agent_LockThenFallback locks the agent and verifies it stops (the
// socket is freed) and subsequent commands fall back to direct-open — rather than
// hitting a locked-but-running agent.
func TestIntegration_Agent_LockThenFallback(t *testing.T) {
// TestIntegration_Agent_StopThenFallback stops the agent and verifies the socket is
// freed promptly and commands fall back to direct-open (password supplied on stdin).
func TestIntegration_Agent_StopThenFallback(t *testing.T) {
configPath, password, service, secret := setupExecVault(t)
sockPath, _, _ := startAgent(t, configPath, password)
sockPath, _, stop := startAgent(t, configPath, password)

// Lock over the socket via `agent lock` (the subcommand form).
if _, stderr, err := runWithAgent(t, configPath, sockPath, "agent", "lock"); err != nil {
t.Fatalf("agent lock failed: %v\nStderr: %s", err, stderr)
// Stop the agent over the socket.
if _, stderr, err := runWithAgent(t, configPath, sockPath, "agent", "stop"); err != nil {
t.Fatalf("agent stop failed: %v\nStderr: %s", err, stderr)
}
stop()

// The socket should be freed promptly (server stopped on the lock).
// The socket should be freed promptly (server stopped on the shutdown).
deadline := time.Now().Add(3 * time.Second)
for {
if _, err := os.Stat(sockPath); os.IsNotExist(err) {
break
}
if time.Now().After(deadline) {
t.Fatal("socket still present after lock — agent did not stop")
t.Fatal("socket still present after stop — agent did not stop")
}
time.Sleep(20 * time.Millisecond)
}

// exec must now fall back to direct-open with the password on stdin.
cmd := exec.Command(binaryPath, "exec", "--set", "TOK="+service, "--", "sh", "-c", `printf %s "$TOK"`)
cmd.Env = append(os.Environ(),
"PASS_CLI_TEST=1", "PASS_CLI_CONFIG="+configPath, "PASS_CLI_AGENT_SOCK="+sockPath)
cmd.Stdin = strings.NewReader(helpers.BuildUnlockStdin(password))
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
t.Fatalf("exec fallback after lock failed: %v\nStderr: %s", err, errb.String())
}
if strings.TrimSpace(out.String()) != secret {
t.Errorf("exec fallback after lock: got %q, want %q", strings.TrimSpace(out.String()), secret)
}
}

// TestIntegration_Agent_StopThenFallback stops the agent and verifies commands fall
// back to direct-open (with the password supplied on stdin).
func TestIntegration_Agent_StopThenFallback(t *testing.T) {
configPath, password, service, secret := setupExecVault(t)
sockPath, _, stop := startAgent(t, configPath, password)

// Stop the agent, then resolve via direct-open with the password on stdin.
if _, stderr, err := runWithAgent(t, configPath, sockPath, "agent", "stop"); err != nil {
t.Fatalf("agent stop failed: %v\nStderr: %s", err, stderr)
}
stop()

// PASS_CLI_AGENT_SOCK still points at the (now-absent) socket; exec must fall
// back to direct-open. Supply the password on stdin.
cmd := exec.Command(binaryPath, "exec", "--set", "TOK="+service, "--", "sh", "-c", `printf %s "$TOK"`)
Expand Down
Loading