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

### Added
- **Env-injection: value filters on resolved references** (#138) — a credential reference may end with `| filter` to transform the value before injection, resolved through the same shared grammar so it works in `${pass:...}` templates, `--set`, and `.pass-cli.toml` manifests: `${pass:api/token | base64}`. Filters: `base64` (standard) and `base64url` (URL-safe) encode a single value; `basicauth` combines a credential's own username and password into `base64("user:pass")` for an HTTP `Authorization: Basic` header (takes no field). One filter per reference; an unknown or empty filter is a hard parse error and nothing is injected (fail closed) — for `--set` and manifests this is caught before the vault is even opened. Filters are applied client-side after resolution, so the agent's values-only protocol is unchanged (identical output in direct and daemon modes). On the `--set` form the `|` must be shell-quoted; filters are not applied to the positional `<service>` form. Item 3 of the #115 epic, completing it.
- **Sync: configurable remote-probe timeout** (#137) — the pre-unlock metadata probe (`rclone lsjson`) was bounded at a hardcoded 8s, so a slow-but-alive remote on a high-latency backend would time out, be misclassified as failed, and enter the failure-backoff (#133) — deferring pulls/pushes for the whole window even though the remote was reachable. New `sync.probe_timeout_seconds` mirrors the `pull_ttl_seconds` tri-state: `0` uses the built-in default (8s), a positive value raises (or lowers) the bound, and a negative value disables it (unbounded probe). Only the metadata probe is bounded — the heavy pull/push transfers remain unbounded regardless.
- **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.
Expand Down
4 changes: 2 additions & 2 deletions cmd/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ it is not process isolation.`,

func init() {
rootCmd.AddCommand(execCmd)
execCmd.Flags().StringArrayVar(&execSets, "set", nil, "map an environment variable to a credential: ENV_NAME=service[/field] (repeatable; ':field' also accepted)")
execCmd.Flags().StringArrayVar(&execSets, "set", nil, "map an environment variable to a credential: ENV_NAME=service[/field][|filter] (repeatable; ':field' also accepted; filters: base64, base64url, basicauth — quote the '|')")
execCmd.Flags().StringVarP(&execField, "field", "f", "password", "field to inject for all mappings (username, password, category, url, notes, service)")
execCmd.Flags().StringArrayVar(&execEnvFiles, "env-file", nil, "read KEY=${pass:service/field} template lines from a file (repeatable)")
execCmd.Flags().StringArrayVar(&execFrom, "from", nil, "read ENV_NAME=service/field mappings from a .pass-cli.toml manifest (repeatable)")
Expand Down Expand Up @@ -271,7 +271,7 @@ func runExec(cmd *cobra.Command, args []string) error {
// ${pass:...} references) against the same read-only resolver.
for _, e := range envEntries {
value, rerr := envmap.RenderTemplate(e.Template, func(refs []envmap.TemplateRef) ([]string, error) {
return r.ResolveValues(templateMappings(refs), execField)
return resolver.ResolveValuesFiltered(r, templateMappings(refs), execField)
})
if rerr != nil {
return fmt.Errorf("env-file key %q: %w", e.Key, rerr)
Expand Down
5 changes: 3 additions & 2 deletions cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"

"github.com/arimxyer/pass-cli/internal/envmap"
"github.com/arimxyer/pass-cli/internal/resolver"
)

var (
Expand Down Expand Up @@ -67,7 +68,7 @@ blessed replacement for VAR="$(pass-cli get ...)", not as a replacement for 'exe

func init() {
rootCmd.AddCommand(exportCmd)
exportCmd.Flags().StringArrayVar(&exportSets, "set", nil, "map an environment variable to a credential: ENV_NAME=service[/field] (repeatable; ':field' also accepted)")
exportCmd.Flags().StringArrayVar(&exportSets, "set", nil, "map an environment variable to a credential: ENV_NAME=service[/field][|filter] (repeatable; ':field' also accepted; filters: base64, base64url, basicauth — quote the '|')")
exportCmd.Flags().StringVarP(&exportField, "field", "f", "password", "field to export for all mappings (username, password, category, url, notes, service)")
exportCmd.Flags().StringVar(&exportFormat, "format", "sh", "shell syntax: sh, fish, or powershell")
exportCmd.Flags().StringArrayVar(&exportFrom, "from", nil, "read ENV_NAME=service/field mappings from a .pass-cli.toml manifest (repeatable)")
Expand Down Expand Up @@ -140,7 +141,7 @@ func runExport(cmd *cobra.Command, args []string) error {
}
defer cleanup()

values, err := r.ResolveValues(mappings, exportField)
values, err := resolver.ResolveValuesFiltered(r, mappings, exportField)
if err != nil {
return err
}
Expand Down
21 changes: 16 additions & 5 deletions cmd/inject.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"

"github.com/arimxyer/pass-cli/internal/envmap"
"github.com/arimxyer/pass-cli/internal/resolver"
)

var (
Expand All @@ -32,11 +33,18 @@ connection string can be materialized in one step.
pass-cli inject -i config.tmpl -o config.ini

Reference syntax:
${pass:service} the service's default field (see -f/--field)
${pass:service/field} a specific field (username, password, url, ...)
${pass:service} the service's default field (see -f/--field)
${pass:service/field} a specific field (username, password, url, ...)
${pass:service/field | flt} apply a filter to the value (see below)

Filters (optional, one per reference):
base64 standard base64 of the value (e.g. a Bearer token)
base64url URL-safe base64 of the value
basicauth base64("username:password") from the credential (takes no field)

Only ${pass:...} is special; $VAR, ${VAR}, and $(...) pass through untouched. An
unknown or malformed reference is a hard error and nothing is written.
unknown or malformed reference — including an unknown filter — is a hard error
and nothing is written.

When the template is piped on stdin, unlock via the OS keychain: the master-
password prompt also reads stdin, so a password prompt would consume the piped
Expand All @@ -49,6 +57,9 @@ is on your terminal/pipe. Prefer 'exec' (child-scoped env) when you can; use
Example: ` # Materialize a connection string
echo 'redis://:${pass:redis/password}@cache:6379' | pass-cli inject

# A base64 Authorization header from a single credential's user:password
echo 'Authorization: Basic ${pass:api | basicauth}' | pass-cli inject

# Render a template file to a 0600 output file
pass-cli inject -i .env.tmpl -o .env`,
Args: cobra.NoArgs,
Expand Down Expand Up @@ -98,7 +109,7 @@ func runInject(cmd *cobra.Command, _ []string) error {
// Read-only via the shared resolver (no usage write, no sync push). All
// references in the template resolve in a single batch call.
rendered, err := envmap.RenderTemplate(string(input), func(refs []envmap.TemplateRef) ([]string, error) {
return r.ResolveValues(templateMappings(refs), injectField)
return resolver.ResolveValuesFiltered(r, templateMappings(refs), injectField)
})
if err != nil {
return err
Expand All @@ -122,7 +133,7 @@ func runInject(cmd *cobra.Command, _ []string) error {
func templateMappings(refs []envmap.TemplateRef) []envmap.Mapping {
ms := make([]envmap.Mapping, len(refs))
for i, ref := range refs {
ms[i] = envmap.Mapping{Service: ref.Service, Field: ref.Field}
ms[i] = envmap.Mapping{Service: ref.Service, Field: ref.Field, Filter: ref.Filter}
}
return ms
}
18 changes: 18 additions & 0 deletions docs/03-reference/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,24 @@ pass-cli exec \
-- ./run.sh
```

**Value filters (`| filter`):** a reference may end with a filter that transforms the value before injection. The same grammar works everywhere a reference is parsed — `--set`, `.pass-cli.toml` manifests, and `${pass:...}` templates (`inject`, `exec --env-file`).

| Filter | Result |
|--------|--------|
| `base64` | standard base64 of the value (e.g. a Bearer token) |
| `base64url` | URL-safe base64 of the value |
| `basicauth` | `base64("username:password")` from the credential — for an HTTP `Authorization: Basic` header; takes no field |

```bash
# base64 a token; combine a credential's user:password for Basic auth
pass-cli exec \
--set 'TOKEN=api/token|base64' \
--set 'AUTH=api|basicauth' \
-- ./run.sh
```

One filter per reference. An unknown or empty filter is a hard error and nothing is injected (fail closed); for `--set` and manifests it is caught before the vault is even opened. On the command line the `|` must be **shell-quoted** (as above). Filters are not applied to the positional `<service>` form — use `--set` or a template.

#### Flags

| Flag | Short | Type | Description |
Expand Down
59 changes: 47 additions & 12 deletions internal/envmap/envmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Mapping struct {
EnvName string
Service string
Field string
Filter string // optional transform applied to the resolved value (e.g. "base64"); "" = none
}

// ParseSetSpec parses a single "NAME=service[/field]" spec into a Mapping. The
Expand All @@ -44,26 +45,60 @@ func ParseSetSpec(spec string) (Mapping, error) {
if !ok || name == "" || rest == "" {
return Mapping{}, fmt.Errorf("invalid mapping %q: expected NAME=service[/field]", spec)
}
service, field, err := SplitPath(rest)
service, field, filter, err := SplitPath(rest)
if err != nil {
return Mapping{}, fmt.Errorf("invalid mapping %q: %w", spec, err)
}
return Mapping{EnvName: name, Service: service, Field: field}, nil
return Mapping{EnvName: name, Service: service, Field: field, Filter: filter}, nil
}

// SplitPath splits a "service[/field]" credential reference into its service and
// optional field. It is the single home for the path separator, shared by
// --set, the project manifest, and ${pass:...} templates.
// SplitPath splits a "service[/field][ | filter]" credential reference into its
// service, optional field, and optional filter. It is the single home for the
// reference grammar, shared by --set, the project manifest, and ${pass:...}
// templates, so a filter added here works on every surface at once.
//
// - Slash is preferred and wins: if the reference contains '/', it is split on
// '/', and any ':' in it is a literal character (the fragility fix). Exactly
// one slash — two segments, service/field — is accepted for now; three or
// more segments error, reserving vault/service/field for a future multi-vault.
// An optional trailing "| filter" is peeled first (on the first '|', which is
// thereby reserved in references like '/' and ':'). The filter name is validated
// against IsKnownFilter and an empty filter is an error — both fail closed at
// parse time (for --set and manifests, before the vault is opened). Whitespace is trimmed ONLY when a pipe
// is present, so a filterless reference is byte-for-byte the original behavior.
// The bare path is then split by splitServiceField. basicauth takes no field.
func SplitPath(ref string) (service, field, filter string, err error) {
path := ref
if left, right, hasPipe := strings.Cut(ref, "|"); hasPipe {
path = strings.TrimSpace(left)
filter = strings.TrimSpace(right)
if filter == "" {
return "", "", "", fmt.Errorf("empty filter after '|': %q", ref)
}
if !IsKnownFilter(filter) {
return "", "", "", fmt.Errorf("unknown filter %q in %q", filter, ref)
}
}

service, field, err = splitServiceField(path)
if err != nil {
return "", "", "", err
}

if filter == FilterBasicAuth && field != "" {
return "", "", "", fmt.Errorf("filter %q takes no field: %q", FilterBasicAuth, ref)
}
return service, field, filter, nil
}

// splitServiceField splits a bare "service[/field]" path (filter already peeled)
// into its service and optional field.
//
// - Slash is preferred and wins: if the path contains '/', it is split on '/',
// and any ':' in it is a literal character (the fragility fix). Exactly one
// slash — two segments, service/field — is accepted for now; three or more
// segments error, reserving vault/service/field for a future multi-vault.
// - Otherwise the legacy colon form applies, byte-for-byte the original
// behavior: the first ':' separates service from an optional field.
// - With no separator at all, the whole reference is the service and the field
// is empty (the caller falls back to its default field).
func SplitPath(ref string) (service, field string, err error) {
// - With no separator at all, the whole path is the service and the field is
// empty (the caller falls back to its default field).
func splitServiceField(ref string) (service, field string, err error) {
if strings.Contains(ref, "/") {
segs := strings.Split(ref, "/")
if len(segs) > 2 {
Expand Down
45 changes: 33 additions & 12 deletions internal/envmap/envmap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,25 @@ func TestParseSetSpec(t *testing.T) {
wantErr bool
}{
// --- legacy colon form (back-compat, unchanged) ---
{name: "service only", spec: "GITHUB_TOKEN=github", want: Mapping{"GITHUB_TOKEN", "github", ""}},
{name: "colon field override", spec: "DB_USER=postgres:username", want: Mapping{"DB_USER", "postgres", "username"}},
{name: "service only", spec: "GITHUB_TOKEN=github", want: Mapping{"GITHUB_TOKEN", "github", "", ""}},
{name: "colon field override", spec: "DB_USER=postgres:username", want: Mapping{"DB_USER", "postgres", "username", ""}},
{name: "empty field after colon", spec: "K=svc:", wantErr: true},
{name: "empty service before colon", spec: "K=:username", wantErr: true},
{name: "no equals", spec: "NOEQUALS", wantErr: true},
{name: "empty service", spec: "K=", wantErr: true},
{name: "empty name", spec: "=github", wantErr: true},
// --- additive slash form (Phase 0b) ---
{name: "slash field", spec: "DB_USER=postgres/username", want: Mapping{"DB_USER", "postgres", "username"}},
{name: "slash colon is literal in service", spec: "URL=my:svc/password", want: Mapping{"URL", "my:svc", "password"}},
{name: "slash field", spec: "DB_USER=postgres/username", want: Mapping{"DB_USER", "postgres", "username", ""}},
{name: "slash colon is literal in service", spec: "URL=my:svc/password", want: Mapping{"URL", "my:svc", "password", ""}},
{name: "slash empty field", spec: "K=svc/", wantErr: true},
{name: "slash empty service", spec: "K=/field", wantErr: true},
{name: "slash multi-segment reserved", spec: "K=vault/svc/field", wantErr: true},
// --- filters (#138) ---
{name: "base64 filter", spec: "TOKEN=api/key|base64", want: Mapping{"TOKEN", "api", "key", "base64"}},
{name: "base64url filter", spec: "TOKEN=api/key|base64url", want: Mapping{"TOKEN", "api", "key", "base64url"}},
{name: "basicauth filter, no field", spec: "AUTH=api|basicauth", want: Mapping{"AUTH", "api", "", "basicauth"}},
{name: "unknown filter", spec: "K=svc|bogus", wantErr: true},
{name: "basicauth with field", spec: "K=svc/user|basicauth", wantErr: true},
}

for _, tt := range tests {
Expand Down Expand Up @@ -91,10 +97,11 @@ func TestValidEnvName(t *testing.T) {
// slash-wins / colon-literal rule that later surfaces (manifest, templates) rely on.
func TestSplitPath(t *testing.T) {
tests := []struct {
ref string
wantSvc string
wantField string
wantErr bool
ref string
wantSvc string
wantField string
wantFilter string
wantErr bool
}{
{ref: "github", wantSvc: "github", wantField: ""},
{ref: "postgres:username", wantSvc: "postgres", wantField: "username"},
Expand All @@ -105,21 +112,35 @@ func TestSplitPath(t *testing.T) {
{ref: "vault/svc/field", wantErr: true}, // 3+ segments reserved
{ref: "svc:", wantErr: true},
{ref: ":field", wantErr: true},
// --- filters (#138) ---
{ref: "api/token|base64", wantSvc: "api", wantField: "token", wantFilter: "base64"},
{ref: "api/token | base64", wantSvc: "api", wantField: "token", wantFilter: "base64"}, // spaces trimmed
{ref: "api/token|base64url", wantSvc: "api", wantField: "token", wantFilter: "base64url"},
{ref: "github|base64", wantSvc: "github", wantField: "", wantFilter: "base64"}, // no field + filter
{ref: "my:svc/pw|base64", wantSvc: "my:svc", wantField: "pw", wantFilter: "base64"},
{ref: "svc:field|base64", wantSvc: "svc", wantField: "field", wantFilter: "base64"}, // legacy colon + filter
{ref: "api|basicauth", wantSvc: "api", wantField: "", wantFilter: "basicauth"},
{ref: "svc/field|", wantErr: true}, // empty filter sentinel
{ref: "svc/field| ", wantErr: true}, // whitespace-only filter
{ref: "svc/field|bogus", wantErr: true}, // unknown filter
{ref: "svc/field|base64|upper", wantErr: true}, // chaining rejected (unknown "base64|upper")
{ref: "api/token|basicauth", wantErr: true}, // basicauth takes no field
{ref: "vault/svc/field|base64", wantErr: true}, // multi-segment still guarded after peel
}
for _, tt := range tests {
t.Run(tt.ref, func(t *testing.T) {
svc, field, err := SplitPath(tt.ref)
svc, field, filter, err := SplitPath(tt.ref)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got service=%q field=%q", svc, field)
t.Fatalf("expected error, got service=%q field=%q filter=%q", svc, field, filter)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if svc != tt.wantSvc || field != tt.wantField {
t.Errorf("SplitPath(%q) = (%q, %q), want (%q, %q)", tt.ref, svc, field, tt.wantSvc, tt.wantField)
if svc != tt.wantSvc || field != tt.wantField || filter != tt.wantFilter {
t.Errorf("SplitPath(%q) = (%q, %q, %q), want (%q, %q, %q)", tt.ref, svc, field, filter, tt.wantSvc, tt.wantField, tt.wantFilter)
}
})
}
Expand Down
Loading
Loading