Skip to content

Commit e7b9122

Browse files
committed
Improve the handling of TUIs when secrets are involved
Signed-off-by: David Gageot <david.gageot@docker.com>
1 parent 413fe6a commit e7b9122

4 files changed

Lines changed: 100 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ The Go toolchain version comes from `go.mod` (`go 1.26.3`). Tests run with
211211
check to before env composition. The new top-level `secrets:` block in
212212
`taskfile/secrets.go` runs as the *last* layer of `buildEnv`, so a
213213
`secrets: [X]` reference still feeds the existing `op://` detection.
214+
When the task's stdout *and* stderr are both terminals, gogo passes
215+
`--no-masking` to `op run` (`opRunArgs` in `taskfile/shell.go`) so
216+
interactive TUIs keep their TTY — op's default masking pipes the
217+
streams through itself and breaks anything more elaborate than line
218+
output. Non-interactive runs (CI, redirected output) keep masking on.
214219
New backends plug in by adding a `case strings.HasPrefix(uri, ...)`
215220
branch in `resolveSecretURI` and a scheme constant in
216221
`supportedSecretSchemes`.

docs/features/secrets/index.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,13 @@ secret "X" has unknown backend in "vault://somewhere/else" (supported: op://)
7272
## Requirements
7373

7474
The `op` CLI must be installed and available on PATH. Install it from <https://developer.1password.com/docs/cli/get-started/>.
75+
76+
## Interactive TUIs
77+
78+
By default `op run` masks secret values found in the wrapped command's
79+
output. To do that it pipes stdout/stderr through itself, which strips the
80+
TTY and breaks any interactive program (e.g. `docker agent eval`, `fzf`,
81+
`less`, `vim`). To keep interactive use working, gogo passes `--no-masking`
82+
to `op run` when both stdout and stderr are attached to a terminal. In
83+
non-interactive runs (CI, output redirected to a file or pipe) the default
84+
masking is left on, so secret leaks in logs are still concealed.

taskfile/shell.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"io"
7+
"os"
78
"os/exec"
89
"strings"
910
"sync"
@@ -100,11 +101,41 @@ func (s *defaultShellRunner) shellExecCommand(req ShellCommand) (*exec.Cmd, erro
100101
if _, err := s.opPath(); err != nil {
101102
return nil, fmt.Errorf("uses op:// secrets but the 1Password CLI (op) is not installed: %w\n\nInstall it from https://developer.1password.com/docs/cli/get-started/", err)
102103
}
103-
return configuredShellCommand(exec.Command("op", "run", "--", "/bin/sh", "-c", req.Command), req), nil
104+
return configuredShellCommand(exec.Command("op", opRunArgs(req)...), req), nil
104105
}
105106
return configuredShellCommand(exec.Command("/bin/sh", "-c", req.Command), req), nil
106107
}
107108

109+
// opRunArgs builds the argv passed to `op run`. When the task's stdout and
110+
// stderr are both attached to a terminal we add `--no-masking` so op leaves
111+
// the streams alone — by default op pipes them through itself to mask
112+
// secret values, which strips the TTY and breaks any TUI the command might
113+
// launch (docker agent eval, fzf, less, vim, …). In non-interactive runs
114+
// (CI, redirected output) we keep masking so accidental secret leaks into
115+
// logs are still concealed.
116+
func opRunArgs(req ShellCommand) []string {
117+
args := []string{"run"}
118+
if isTerminal(req.Stdout) && isTerminal(req.Stderr) {
119+
args = append(args, "--no-masking")
120+
}
121+
return append(args, "--", "/bin/sh", "-c", req.Command)
122+
}
123+
124+
// isTerminal reports whether w is a *os.File backed by a character device,
125+
// i.e. a terminal. Anything else (a pipe, buffer, regular file, or nil)
126+
// returns false.
127+
func isTerminal(w io.Writer) bool {
128+
f, ok := w.(*os.File)
129+
if !ok || f == nil {
130+
return false
131+
}
132+
fi, err := f.Stat()
133+
if err != nil {
134+
return false
135+
}
136+
return fi.Mode()&os.ModeCharDevice != 0
137+
}
138+
108139
func configuredShellCommand(cmd *exec.Cmd, req ShellCommand) *exec.Cmd {
109140
cmd.Dir = req.Dir
110141
cmd.Env = req.Env

taskfile/shell_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package taskfile
22

33
import (
4+
"bytes"
45
"errors"
6+
"os"
7+
"slices"
58
"sync"
69
"testing"
710

@@ -51,3 +54,53 @@ func TestDefaultShellRunnerCachesOpLookup(t *testing.T) {
5154
}
5255
assert.Equal(t, 1, calls, "op lookup must only happen once per runner")
5356
}
57+
58+
func TestOpRunUsesNoMaskingWhenStdioIsATerminal(t *testing.T) {
59+
// `op run` masks secrets in stdout/stderr by piping them through itself,
60+
// which strips the TTY and breaks any interactive TUI the command may
61+
// launch (docker agent eval, fzf, less, vim, …). When both streams are
62+
// terminals we pass --no-masking so op leaves them untouched.
63+
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
64+
if err != nil {
65+
t.Skipf("no /dev/tty available: %v", err)
66+
}
67+
t.Cleanup(func() { _ = tty.Close() })
68+
69+
args := opRunArgs(ShellCommand{
70+
Command: "echo hi",
71+
Stdout: tty,
72+
Stderr: tty,
73+
})
74+
assert.Contains(t, args, "--no-masking")
75+
assert.Equal(t, []string{"--", "/bin/sh", "-c", "echo hi"}, args[len(args)-4:])
76+
}
77+
78+
func TestOpRunKeepsMaskingWhenStdioIsNotATerminal(t *testing.T) {
79+
// In CI / scripts (output redirected to a buffer or a file) we keep the
80+
// default masking so accidental secret leaks into logs are still
81+
// concealed.
82+
args := opRunArgs(ShellCommand{
83+
Command: "echo hi",
84+
Stdout: &bytes.Buffer{},
85+
Stderr: &bytes.Buffer{},
86+
})
87+
assert.False(t, slices.Contains(args, "--no-masking"))
88+
assert.Equal(t, []string{"run", "--", "/bin/sh", "-c", "echo hi"}, args)
89+
}
90+
91+
func TestOpRunKeepsMaskingWhenOnlyStderrIsATerminal(t *testing.T) {
92+
// Half-redirected: piping just stdout (e.g. `gogo evals | tee log`) is a
93+
// non-interactive use, so masking stays on.
94+
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
95+
if err != nil {
96+
t.Skipf("no /dev/tty available: %v", err)
97+
}
98+
t.Cleanup(func() { _ = tty.Close() })
99+
100+
args := opRunArgs(ShellCommand{
101+
Command: "echo hi",
102+
Stdout: &bytes.Buffer{},
103+
Stderr: tty,
104+
})
105+
assert.False(t, slices.Contains(args, "--no-masking"))
106+
}

0 commit comments

Comments
 (0)