diff --git a/plugins/pass/commands/run.go b/plugins/pass/commands/run.go index 85474dfc..42546e5f 100644 --- a/plugins/pass/commands/run.go +++ b/plugins/pass/commands/run.go @@ -53,6 +53,7 @@ var runLong string type runOpts struct { clientOpts envFiles []string + env []string } // RunCommand uses [api.DesktopSocketPath] by default. @@ -72,7 +73,7 @@ func newRunCommand(opts runOpts) *cobra.Command { Example: strings.Trim(runExample, "\n"), Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - merged, err := mergeEnv(os.Environ(), opts.envFiles) + merged, err := mergeEnv(os.Environ(), opts.envFiles, opts.env) if err != nil { return err } @@ -147,10 +148,12 @@ func newRunCommand(opts runOpts) *cobra.Command { } cmd.Flags().StringArrayVar(&opts.envFiles, "env-file", nil, "Read environment variables from a dotenv-formatted file. Repeatable; later files override earlier files and the process environment.") + cmd.Flags().StringArrayVarP(&opts.env, "env", "e", nil, + "Set an environment variable as `KEY=VALUE`; the value may be an se:// reference. Repeatable; overrides --env-file and the process environment.") return cmd } -func mergeEnv(processEnv, files []string) ([]string, error) { +func mergeEnv(processEnv, files, env []string) ([]string, error) { merged := make(map[string]string, len(processEnv)) for _, kv := range processEnv { if k, v, ok := strings.Cut(kv, "="); ok { @@ -164,6 +167,13 @@ func mergeEnv(processEnv, files []string) ([]string, error) { } maps.Copy(merged, parsed) } + for _, kv := range env { + k, v, ok := strings.Cut(kv, "=") + if !ok || k == "" { + return nil, fmt.Errorf("invalid --env %q: expected KEY=VALUE", kv) + } + merged[k] = v + } out := make([]string, 0, len(merged)) for k, v := range merged { out = append(out, k+"="+v) diff --git a/plugins/pass/commands/run_example.md b/plugins/pass/commands/run_example.md index 414b0134..21519d4a 100644 --- a/plugins/pass/commands/run_example.md +++ b/plugins/pass/commands/run_example.md @@ -1,29 +1,41 @@ ### Run a command with one secret in its environment: ```console -$ SE_TOKEN=se://gh-token docker pass run -- gh repo list +$ docker pass run -e GH_TOKEN=se://gh-token -- gh repo list ``` -### Multiple references: +### Pass several secrets to a shell script, which reads them like any other variable: ```console -$ DB_PASSWORD=se://myapp/postgres/password API_KEY=se://myapp/anthropic/api-key docker pass run -- ./my-binary +$ docker pass run -e DB_PASSWORD=se://myapp/password -e API_KEY=se://myapp/api-key -- ./deploy.sh +``` + +### Resolve references from a dotenv file: + +```console +$ docker pass run --env-file .env -- ./deploy.sh +``` + +### Inline shell (single quotes leave `$DB_PASSWORD` for the child shell to expand): + +```console +$ docker pass run -e DB_PASSWORD=se://myapp/password -- sh -c 'pg_dump "postgres://app:$DB_PASSWORD@db/app" > backup.sql' ``` ### Wildcard pattern (the first matching secret is used): ```console -$ DB_PASSWORD=se://*/postgres/password docker pass run -- ./my-binary +$ docker pass run -e DB_PASSWORD=se://*/password -- ./deploy.sh ``` -### Resolve references from a dotenv file: +### References already in the calling environment are resolved too: ```console -$ docker pass run --env-file .env -- ./my-binary +$ GH_TOKEN=se://gh-token docker pass run -- gh repo list ``` -### Multiple files (later overrides earlier; files override the process environment): +### Combine sources (`--env` overrides `--env-file`; later files override earlier ones; all override the process environment): ```console -$ docker pass run --env-file .env --env-file .env.local -- ./my-binary +$ docker pass run --env-file .env --env-file .env.local -e DB_PASSWORD=se://myapp/postgres/password -- ./deploy.sh ``` diff --git a/plugins/pass/commands/run_long.md b/plugins/pass/commands/run_long.md index ef8209b5..3fbf0496 100644 --- a/plugins/pass/commands/run_long.md +++ b/plugins/pass/commands/run_long.md @@ -1,7 +1,8 @@ -Scans the current environment (plus any `--env-file` inputs) for variables -whose value is exactly `se://`. Each reference is resolved through the -secrets-engine daemon and the resolved value is passed to the child process. -When several secrets match, the first match is used. +Scans the current environment (plus any `--env-file` and `--env` inputs) for +variables whose value is exactly `se://`. Each reference is resolved +through the secrets-engine daemon and the resolved value is passed to the child +process. When several secrets match, the first match is used. +`--env` overrides `--env-file`; both override the process environment. The child inherits stdin, stdout, and stderr. Requires the secrets-engine daemon (Docker Desktop) to be running. diff --git a/plugins/pass/commands/run_test.go b/plugins/pass/commands/run_test.go index a4215095..22f6cbbc 100644 --- a/plugins/pass/commands/run_test.go +++ b/plugins/pass/commands/run_test.go @@ -249,14 +249,14 @@ func TestMergeEnv(t *testing.T) { t.Parallel() t.Run("no files returns sorted process env", func(t *testing.T) { - out, err := mergeEnv([]string{"B=2", "A=1"}, nil) + out, err := mergeEnv([]string{"B=2", "A=1"}, nil, nil) require.NoError(t, err) assert.Equal(t, []string{"A=1", "B=2"}, out) }) t.Run("file overrides process env", func(t *testing.T) { f := writeEnvFile(t, "A=from-file\nC=new\n") - out, err := mergeEnv([]string{"A=from-process", "B=keep"}, []string{f}) + out, err := mergeEnv([]string{"A=from-process", "B=keep"}, []string{f}, nil) require.NoError(t, err) assert.Equal(t, []string{"A=from-file", "B=keep", "C=new"}, out) }) @@ -264,14 +264,14 @@ func TestMergeEnv(t *testing.T) { t.Run("later file overrides earlier file", func(t *testing.T) { f1 := writeEnvFile(t, "A=from-file-1\n") f2 := writeEnvFile(t, "A=from-file-2\n") - out, err := mergeEnv(nil, []string{f1, f2}) + out, err := mergeEnv(nil, []string{f1, f2}, nil) require.NoError(t, err) assert.Equal(t, []string{"A=from-file-2"}, out) }) t.Run("comments and quoted values", func(t *testing.T) { f := writeEnvFile(t, "# this is a comment\nGREETING=\"hello world\"\nQUOTED='no $expand'\n") - out, err := mergeEnv(nil, []string{f}) + out, err := mergeEnv(nil, []string{f}, nil) require.NoError(t, err) assert.Equal(t, []string{ "GREETING=hello world", @@ -281,17 +281,45 @@ func TestMergeEnv(t *testing.T) { t.Run("missing file returns error and does not partially apply", func(t *testing.T) { f := writeEnvFile(t, "A=present\n") - out, err := mergeEnv([]string{"B=keep"}, []string{f, "/does/not/exist/.env"}) + out, err := mergeEnv([]string{"B=keep"}, []string{f, "/does/not/exist/.env"}, nil) require.Error(t, err) assert.Nil(t, out) assert.Contains(t, err.Error(), "/does/not/exist/.env") }) + t.Run("--env overrides files and the process env", func(t *testing.T) { + f := writeEnvFile(t, "A=from-file\nB=from-file\n") + out, err := mergeEnv([]string{"A=from-process", "C=keep"}, []string{f}, []string{"A=from-flag", "D=new"}) + require.NoError(t, err) + assert.Equal(t, []string{"A=from-flag", "B=from-file", "C=keep", "D=new"}, out) + }) + + t.Run("later --env overrides earlier --env", func(t *testing.T) { + out, err := mergeEnv(nil, nil, []string{"A=1", "A=2"}) + require.NoError(t, err) + assert.Equal(t, []string{"A=2"}, out) + }) + + t.Run("--env splits at the first = and allows an empty value", func(t *testing.T) { + out, err := mergeEnv(nil, nil, []string{"CHECK=KEY=WANT", "EMPTY="}) + require.NoError(t, err) + assert.Equal(t, []string{"CHECK=KEY=WANT", "EMPTY="}, out) + }) + + t.Run("--env without KEY=VALUE is rejected", func(t *testing.T) { + for _, bad := range []string{"NOVALUE", "=value", ""} { + out, err := mergeEnv([]string{"B=keep"}, nil, []string{bad}) + require.Error(t, err, bad) + assert.Nil(t, out) + assert.ErrorContains(t, err, "expected KEY=VALUE") + } + }) + t.Run("preserves se:// values for downstream parseEnv", func(t *testing.T) { f := writeEnvFile(t, "SE_TOKEN=se://gh-token\nPLAIN=v\n") - out, err := mergeEnv(nil, []string{f}) + out, err := mergeEnv(nil, []string{f}, []string{"PG_PWD=se://*/postgres/password"}) require.NoError(t, err) - assert.Equal(t, []string{"PLAIN=v", "SE_TOKEN=se://gh-token"}, out) + assert.Equal(t, []string{"PG_PWD=se://*/postgres/password", "PLAIN=v", "SE_TOKEN=se://gh-token"}, out) }) } @@ -434,6 +462,41 @@ func TestRunCommand(t *testing.T) { assert.Equal(t, []string{"authorize gh-token", "resolve gh-token"}, engine.recorded()) }) + t.Run("resolves references set with --env", func(t *testing.T) { + engine := &mockEngine{allow: true, store: map[secrets.ID]string{ + secrets.MustParseID("gh-token"): "ghp_abc123", + }} + // The env-file plants a dangling reference; --env must win over it. + envFile := writeEnvFile(t, "SE_TOKEN=se://does-not-exist\n") + cmd, err := RunCommand(WithTimeout(time.Second), WithSocketPath(engine.serve(t))) + require.NoError(t, err) + cmd.SetArgs([]string{ + "--env-file", envFile, + "-e", "SE_TOKEN=se://gh-token", + "-e", helperActiveEnv + "=1", + "--env", helperCheckEnv + "=SE_TOKEN=ghp_abc123", + exe, + }) + cmd.SetContext(t.Context()) + cmd.SetOut(testWriter{t}) + cmd.SetErr(testWriter{t}) + require.NoError(t, cmd.Execute()) + assert.Equal(t, []string{"authorize gh-token", "resolve gh-token"}, engine.recorded()) + }) + + t.Run("a malformed --env fails before contacting the engine", func(t *testing.T) { + engine := &mockEngine{allow: true} + cmd, err := RunCommand(WithTimeout(time.Second), WithSocketPath(engine.serve(t))) + require.NoError(t, err) + cmd.SetArgs([]string{"-e", "NOVALUE", exe}) + cmd.SetContext(t.Context()) + cmd.SetOut(testWriter{t}) + cmd.SetErr(testWriter{t}) + err = cmd.Execute() + require.ErrorContains(t, err, `invalid --env "NOVALUE": expected KEY=VALUE`) + assert.Empty(t, engine.recorded()) + }) + t.Run("a denied authorization stops before resolving", func(t *testing.T) { engine := &mockEngine{store: map[secrets.ID]string{ secrets.MustParseID("gh-token"): "ghp_abc123",