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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.18.0] - 2026-06-26

### Added
- **`exec` command — inject credentials as environment variables** (#98) — `pass-cli exec` runs a child command with stored credentials passed only through its environment, so the secret never touches a file, the clipboard, or shell history. Supports an explicit, repeatable `--set ENV_NAME=service[:field]` mapping and a convenience form (`pass-cli exec <service> -- <cmd>`) that derives the env name from the service (uppercased, non-alphanumeric → `_`, e.g. `openai-api` → `OPENAI_API`). The `-f/--field` flag selects the field for all mappings (default `password`); a per-mapping `:field` suffix overrides it, allowing two fields of one entry to be injected as separate variables. Everything after `--` is the child argv, and the child's exit code is propagated unchanged. `exec` is read-only: it records no usage and triggers no sync push, making it safe on a hot path.

### Changed
- **`list` is now safe-by-default** (#95, #97) — the default table hides the Username column, because the "username" field can hold sensitive values (card, account, or routing numbers stored as a username). New `--show-usernames` re-adds the column, and new `-q/--quiet` is a shorthand for `--format simple` (bare service names, one per line) that takes precedence over `--format`. `--format json` is unchanged and still emits full metadata including usernames as an explicit, structured opt-in.
- **Sync: content-hash change detection** (#102) — push change-detection now uses a name-encoded zero-byte marker (`vault.enc.<sha256>.synchash`) written next to the vault on each push, replacing the old modtime+size heuristic; older vaults without a marker fall back to the previous behavior.
- **Sync: lower unlock latency by overlapping the pull** (#103) — the pre-unlock remote pull now runs concurrently with the master-password prompt (Tier 1, #109) and with PBKDF2-SHA256 key derivation on keychain unlock (Tier 2, #110). Internal only; no flag or UX change.
- **Sync: cut sync-related startup latency** (#104) — removed the dead `--hash` flag and the "Syncing… done" feedback line. The global `--offline` flag is unaffected and still works.

### Fixed
- **Test: resolve `captureStdout` redeclaration across `cmd` test files** (#99)

### Infrastructure
- **Tracked `mise.toml`** (#100) — `mise.toml` is now committed so the documented `mise run …` tasks work on a fresh clone (previously gitignored).
- **CI runs on all PRs** (#105) — required status checks now report for non-code-change PRs as well.
- **pass-cli agent skill** (#101) — added an agent skill under `.claude/skills` and tracked the directory.

## [0.17.2] - 2026-01-31

### Changed
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ mise run gh <args> # GitHub CLI operations

- Vault is a single encrypted file — all pushes/pulls transfer one file regardless of change count
- `SmartPush` hashes local file, skips network if hash matches `LastPushHash` in `.sync-state`
- Change detection is content-hash based (#102): each push writes a zero-byte marker `vault.enc.<sha256>.synchash` next to the vault; `SmartPull` reads the hash from the marker name in the `rclone lsjson` listing it already fetches (no extra round trip), replacing the old modtime+size heuristic — markerless older vaults fall back to it
- `SmartPull` runs before unlock, `SmartPush` runs after command completes (synchronous, blocks prompt)
- The pre-unlock pull overlaps unlock work (#103): the master-password prompt (Tier 1) and, on keychain unlock, the PBKDF2 key derivation (Tier 2) run concurrently with the pull, which joins before decrypt — see `cmd/helpers.go`
- `RecordFieldAccess` (called by `get`) writes usage timestamps, changing vault hash and triggering push
- Two network round-trips per push: `rclone sync` + `rclone lsjson`
- Async push risks: silent failures, false conflicts (kill between push and SaveState), partial uploads on some backends
Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,16 @@ pass-cli add github
# Retrieve a credential
pass-cli get github

# List all credentials
# List all credentials (usernames hidden by default; -q for bare names)
pass-cli list

# Run a command with a credential injected as an environment variable
# (the secret never touches a file, the clipboard, or your shell history)
pass-cli exec --set GITHUB_TOKEN=github -- gh repo list

# Convenience form: derive the env name from the service (github -> GITHUB)
pass-cli exec github -- gh repo list

# Use in scripts (quiet mode)
export API_KEY=$(pass-cli get myservice --quiet --field password)
```
Expand Down Expand Up @@ -109,9 +116,18 @@ pass-cli get github
# Get credential (script-friendly)
pass-cli get github --quiet --field password

# List all credentials
# Run a command with a credential injected as an env var (never hits disk/clipboard)
pass-cli exec --set GITHUB_TOKEN=github -- gh repo list

# List all credentials (table hides usernames by default)
pass-cli list

# List bare service names, one per line
pass-cli list -q

# List including the username column (may reveal sensitive values)
pass-cli list --show-usernames

# Update credential
pass-cli update github --username newuser@example.com

Expand Down
168 changes: 158 additions & 10 deletions docs/02-guides/scripting-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,119 @@ pass-cli get github --field username --quiet

### Simple Mode (List Only)

Service names only, one per line.
Service names only, one per line. The `-q/--quiet` flag is a shorthand for
`--format simple` and takes precedence over `--format`.

```bash
pass-cli list --format simple
pass-cli list -q
# github
# aws-prod
# database

# Equivalent, explicit form
pass-cli list --format simple
```

**Username safety:** the default `list` table **hides the Username column**, because the
"username" field can hold sensitive values (card, account, or routing numbers stored as a
username). Pass `--show-usernames` to add the column back:

```bash
pass-cli list --show-usernames
```

Note that `--format json` is an explicit, structured opt-in and still emits the full
metadata, **including usernames**:

```bash
pass-cli list --format json
```

## Inject Credentials with `exec` (Recommended)

When the goal is to hand a credential to a child command, prefer `pass-cli exec`.
It injects the secret as an **environment variable** in the child process only - the
value never touches a file, the clipboard, or your shell history, and `pass-cli`
writes nothing of its own to stdout. This avoids the leak surface of command
substitution (covered [below](#capturing-values-with-command-substitution-fallback)),
where any layer that echoes the command - `set -x`, CI job logs, file-watching tools -
can capture the value.

`exec` is read-only: it does not record usage or trigger a sync push, so it is safe to
call repeatedly on a hot path (for example, inside an agent or a tight CI loop).

**Explicit mapping (`--set ENV_NAME=service`, repeatable):**

```bash
# Inject the password of "github" as GITHUB_TOKEN, then run gh
pass-cli exec --set GITHUB_TOKEN=github -- gh repo list
```

Everything after `--` is the command to run. There must be a `--` followed by a
command, or `exec` errors.

**Convenience form (derive the env name from the service):**

```bash
# Service name -> env name: uppercased, every non-alphanumeric char -> '_'
# "openai-api" sets OPENAI_API to the password field.
pass-cli exec openai-api -- python train.py
```

Do not combine a positional `<service>` with `--set` - pick one form per command.

**Multiple credentials at once:**

```bash
pass-cli exec \
--set AWS_ACCESS_KEY_ID=aws-id \
--set AWS_SECRET_ACCESS_KEY=aws-secret \
-- aws s3 ls
```

**Selecting a field.** `-f/--field` (default `password`) selects the field for every
mapping; valid fields are `username`, `password`, `category`, `url`, `notes`, `service`.

```bash
# Inject the username field instead of the password
pass-cli exec --set DB_USER=postgres --field username -- ./run-migration.sh
```

**Two fields of one entry as separate variables.** A per-mapping `:field` suffix
overrides `--field`, so you can inject both a username and a password from the same
credential:

```bash
pass-cli exec \
--set DB_USER=postgres:username \
--set DB_PASSWORD=postgres:password \
-- ./run-migration.sh
```

**Exit-code propagation.** The child's exit code is propagated unchanged, so error
handling works exactly as if you had run the command directly:

```bash
pass-cli exec --set GITHUB_TOKEN=github -- gh repo list
echo "exit code: $?" # the exit code of gh, untouched by pass-cli
```

**Security note (honest scope).** The injected value lives in the child process's
environment. On Linux it is readable via `/proc/<pid>/environ` by the same user and is
inherited by descendant processes. This is the same model as `op run` and
`aws-vault exec` - far safer than files, clipboards, or shell history, but it is **not**
process isolation.

## Script Integration

### Capturing Values with Command Substitution (Fallback)

When you need the secret *inside your own script* - not just to launch a child command -
capture it with command substitution. This pattern works, but the value is exposed to
anything that echoes the command (`set -x`, CI job logs, file-watching tools), so prefer
[`pass-cli exec`](#inject-credentials-with-exec-recommended) whenever you are simply
handing the credential to another command.

### Bash/Zsh Examples

**Export to environment variable:**
Expand All @@ -65,6 +167,9 @@ mysql -u "$(pass-cli get testservice -f username -q)" \
mydb
```

> **Tip:** To run `mysql` without the password landing in your shell, use
> `exec` instead: `pass-cli exec --set MYSQL_PWD=testservice -- mysql -u <user> mydb`.

**Conditional execution:**

```bash
Expand All @@ -82,7 +187,7 @@ fi

```bash
# Process all credentials
for service in $(pass-cli list --format simple); do
for service in $(pass-cli list -q); do
echo "Processing $service..."
username=$(pass-cli get "$service" --field username --quiet)
echo " Username: $username"
Expand Down Expand Up @@ -147,13 +252,22 @@ username = result.stdout.strip()

### Makefile Examples

Lead with `exec` so the secrets are not interpolated into recipe lines (which `make`
echoes by default unless prefixed with `@`):

```makefile
.PHONY: deploy
deploy:
@export AWS_KEY=$$(pass-cli get aws --quiet --field username); \
export AWS_SECRET=$$(pass-cli get aws --quiet); \
./deploy.sh
@pass-cli exec \
--set AWS_KEY=aws:username \
--set AWS_SECRET=aws:password \
-- ./deploy.sh
```

Command substitution is still the right tool when you must *build* a value (like a DSN)
inside the recipe; keep it under `@` so the line is not echoed:

```makefile
.PHONY: test-db
test-db:
@DB_URL="postgres://$$(pass-cli get testdb -f username -q):$$(pass-cli get testdb -q)@localhost/testdb" \
Expand Down Expand Up @@ -196,10 +310,11 @@ pass-cli get github

### Scripting

1. **Always use `--quiet`** - Clean output for variables
2. **Check exit codes** - Handle errors properly
3. **Use `--field`** - Extract exactly what you need
4. **Redirect stderr** - Control error output
1. **Prefer `exec` to hand a credential to a command** - Keeps the value out of files, history, and logs
2. **Use `--quiet` when you must capture a value** - Clean output for variables
3. **Check exit codes** - Handle errors properly (`exec` propagates the child's code unchanged)
4. **Use `--field`** - Extract exactly what you need
5. **Redirect stderr** - Control error output

### Examples

Expand All @@ -222,6 +337,21 @@ pass-cli add service --password mySecretPassword

### CI/CD Pipeline

Inject the deployment credentials straight into the deploy command with `exec`, so the
values never appear in the job log (unlike `export VAR=$(...)`, which CI logging or
`set -x` can capture). `deploy.sh` reads `$DEPLOY_KEY` and `$DB_PASSWORD` from its
environment, and its exit code propagates unchanged:

```bash
pass-cli exec \
--set DEPLOY_KEY=production \
--set DB_PASSWORD=prod-db \
-- ./deploy.sh
```

<details>
<summary>Command-substitution fallback (exposes values to job logs / <code>set -x</code>)</summary>

```bash
# Retrieve deployment credentials
export DEPLOY_KEY=$(pass-cli get production --quiet)
Expand All @@ -231,8 +361,24 @@ export DB_PASSWORD=$(pass-cli get prod-db --quiet)
./deploy.sh
```

</details>

### Local Development

Pull several fields of one entry (`dev-db`) into separate variables with per-mapping
`:field` overrides, and launch the dev server in one step:

```bash
pass-cli exec \
--set DB_HOST=dev-db:url \
--set DB_USER=dev-db:username \
--set DB_PASS=dev-db:password \
-- npm run dev
```

<details>
<summary>Command-substitution fallback (exposes values to shell tracing)</summary>

```bash
# Set up environment from credentials
export DB_HOST=$(pass-cli get dev-db --field url --quiet)
Expand All @@ -243,6 +389,8 @@ export DB_PASS=$(pass-cli get dev-db --quiet)
npm run dev
```

</details>

### Credential Rotation

```bash
Expand Down
Loading
Loading