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. `agent stop`, `agent status`, and `lock` manage a running agent (status 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. `lock` and `agent stop` both zero the resident secrets and stop the agent promptly (freeing the socket so the next command falls back to direct-open; re-run `pass-cli agent` 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
7 changes: 6 additions & 1 deletion cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@ func runAgent(cmd *cobra.Command, _ []string) error {
// or casually ptraced. Best-effort: a failure (e.g. a low RLIMIT_MEMLOCK) is a
// warning, not fatal — the agent is then no worse off than a one-shot command.
if err := agent.HardenProcessMemory(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Warning: could not harden agent memory (%v); continuing without mlock\n", err)
// PR_SET_DUMPABLE=0 is applied first and rarely fails, so core-dump/ptrace
// protection is active even here — only the swap-lock (mlock) is unavailable,
// which is expected without a raised RLIMIT_MEMLOCK. Keep the tone calm.
_, _ = fmt.Fprintf(os.Stderr, "note: agent memory not locked into RAM (%v).\n"+
" core-dump and ptrace protection are still active; raise RLIMIT_MEMLOCK "+
"(e.g. systemd LimitMEMLOCK=infinity) to enable mlock.\n", err)
}

vaultPath := GetVaultPath()
Expand Down
51 changes: 38 additions & 13 deletions cmd/lock.go
Original file line number Diff line number Diff line change
@@ -1,31 +1,56 @@
package cmd

import (
"errors"
"fmt"

"github.com/spf13/cobra"

"github.com/arimxyer/pass-cli/internal/agent"
)

// runLock is shared by the top-level `pass-cli lock` and `pass-cli agent lock`.
func runLock(cmd *cobra.Command, _ []string) error {
if err := agent.LockAgent(); err != nil {
if errors.Is(err, agent.ErrNoAgent) {
// Nothing to lock is not a failure — a one-shot command already locks the
// vault when it exits.
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "no agent is running (nothing to lock)")
return nil
}
return err
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "agent locked and stopped")
return nil
}

const lockLong = `Lock zeroes the secrets held by a running agent and stops it, freeing the
socket. Subsequent commands fall back to opening the vault directly (with a
prompt); to re-establish the agent, run 'pass-cli agent' again. If no agent is
running there is nothing to lock (a one-shot command already locks the vault
when it exits).`

// lockCmd is the top-level shortcut: `pass-cli lock`.
var lockCmd = &cobra.Command{
Use: "lock",
GroupID: "vault",
Short: "Lock a running agent's vault without stopping the process",
Long: `Lock zeroes the secrets held by a running agent, so subsequent lookups require
a fresh unlock, while leaving the agent process running. If no agent is running
there is nothing to lock (a one-shot command already locks the vault when it
exits).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := agent.LockAgent(); err != nil {
return err
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "agent vault locked")
return nil
},
Short: "Lock the running agent (zero its secrets and stop it)",
Long: lockLong,
Args: cobra.NoArgs,
RunE: runLock,
}

// agentLockCmd is the same action under `agent`, where it sits next to
// `agent stop` and `agent status` — the first place people look for it.
var agentLockCmd = &cobra.Command{
Use: "lock",
Short: "Lock the running agent (zero its secrets and stop it)",
Long: lockLong,
Args: cobra.NoArgs,
RunE: runLock,
}

func init() {
rootCmd.AddCommand(lockCmd)
agentCmd.AddCommand(agentLockCmd)
}
3 changes: 2 additions & 1 deletion internal/agent/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ func Stop() error {
return nil
}

// LockAgent locks a running agent's vault without stopping the process.
// 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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ 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, keep the process alive
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
)
Expand Down
7 changes: 6 additions & 1 deletion internal/agent/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ func (s *Server) handleConn(conn net.Conn) {
resp := s.agent.Handle(req)
_ = json.NewEncoder(conn).Encode(resp)

if req.Method == MethodShutdown && resp.OK {
// 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.
if s.agent.Locked() {
s.Stop()
}
}
Expand Down
29 changes: 29 additions & 0 deletions internal/agent/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,32 @@ func TestServer_ShutdownStops(t *testing.T) {
t.Fatalf("pre-shutdown resolve failed: %v", err)
}
}

// 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) {
stop := startTestAgent(t)
defer stop()

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

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

// The socket must become unreachable quickly (server stopped on the lock).
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")
}
time.Sleep(5 * time.Millisecond)
}
}
40 changes: 40 additions & 0 deletions test/integration/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,46 @@ func TestIntegration_Agent_MemoryHardened(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) {
configPath, password, service, secret := setupExecVault(t)
sockPath, _, _ := 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)
}

// The socket should be freed promptly (server stopped on the lock).
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")
}
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) {
Expand Down
Loading