detached fff3337

fix(cli): run token.cmd only for the server of the config file

A token fetched for the url of the file must not be sent to another server named by ERMITE_URL. When ERMITE_URL differs, the call is anonymous and a notice says to set ERMITE_TOKEN.
commit
fff33377274686d95de8d2758f832b168febc8c4
author
brice · · 2026-10-12 15:07 UTC
parent
6f11e7d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +66 -8
M README.md +1 -1
@@ -55,7 +55,7 @@ ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermi
5555 ermite config show # path and stored values; unset removes a key
5656 ```
5757
58-`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and terminal (so a pinentry or biometric prompt works). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file is writable by other users. A failure is reported in one line without its output. A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.
58+`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and terminal (so a pinentry or biometric prompt works). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file is writable by other users. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.
5959
6060 ```sh
6161 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
@@ -55,7 +55,7 @@ ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermi
55 ermite config show # path and stored values; unset removes a key55 ermite config show # path and stored values; unset removes a key
56 ```56 ```
57 57
58-`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and terminal (so a pinentry or biometric prompt works). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file is writable by other users. A failure is reported in one line without its output. A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.58+`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and terminal (so a pinentry or biometric prompt works). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file is writable by other users. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.
59 59
60 ```sh60 ```sh
61 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...61 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
M internal/app/cli_config.go +3 -2
@@ -32,8 +32,9 @@ func newConfigCmd(env *cliEnv) *cobra.Command {
3232 "token.cmd is a command line run with the system shell when a command needs the token (at most\n" +
3333 "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" +
3434 "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" +
35- "take precedence over the file; `ermite version` and `ermite config` never run token.cmd. The\n" +
36- "command is not run when the file is writable by other users.",
35+ "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" +
36+ "ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" +
37+ "notice: set ERMITE_TOKEN). `ermite version` and `ermite config` never run it.",
3738 }
3839 cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
3940 return cmd
@@ -32,8 +32,9 @@ func newConfigCmd(env *cliEnv) *cobra.Command {
32 "token.cmd is a command line run with the system shell when a command needs the token (at most\n" +32 "token.cmd is a command line run with the system shell when a command needs the token (at most\n" +
33 "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" +33 "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" +
34 "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" +34 "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" +
35- "take precedence over the file; `ermite version` and `ermite config` never run token.cmd. The\n" +35+ "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" +
36- "command is not run when the file is writable by other users.",36+ "ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" +
37+ "notice: set ERMITE_TOKEN). `ermite version` and `ermite config` never run it.",
37 }38 }
38 cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))39 cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
39 return cmd40 return cmd
M internal/app/cli_configfile.go +28 -2
@@ -107,7 +107,10 @@ func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, er
107107 return e.tokenFromCmd(cmd, warn)
108108 }
109109
110-// tokenFromCmd is the output of token.cmd, or "" when the file sets none.
110+// tokenFromCmd is the output of token.cmd, or "" when the file sets none or
111+// when ERMITE_URL points somewhere else than the url of the file: the token
112+// the command fetches belongs to the server of the file, and must not be sent
113+// to another one.
111114 func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
112115 f, path, err := e.readFile(cmd, warn)
113116 if err != nil {
@@ -117,10 +120,33 @@ func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
117120 if line == "" {
118121 return "", nil
119122 }
120- e.tokenOnce.Do(func() { e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) })
123+ e.tokenOnce.Do(func() {
124+ if !e.urlIsFromFile(f) {
125+ if warn {
126+ noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken)
127+ }
128+ return
129+ }
130+ e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line)
131+ })
121132 return e.token, e.tokenErr
122133 }
123134
135+// urlIsFromFile reports whether the server in use is the one of the config
136+// file: ERMITE_URL is unset, or names the same address as the file's url.
137+func (e *cliEnv) urlIsFromFile(f *cliFile) bool {
138+ envURL := strings.TrimSpace(e.getenv(client.EnvURL))
139+ if envURL == "" {
140+ return true
141+ }
142+ a, err := client.ParseURL(envURL)
143+ if err != nil {
144+ return false
145+ }
146+ b, err := client.ParseURL(strings.TrimSpace(f.URL))
147+ return err == nil && a.String() == b.String()
148+}
149+
124150 // fetchToken runs token.cmd. Neither its output nor its error text reach the
125151 // caller's messages: the output is the secret.
126152 func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) {
@@ -107,7 +107,10 @@ func (e *cliEnv) resolveToken(cmd *cobra.Command, warn, runCmd bool) (string, er
107 return e.tokenFromCmd(cmd, warn)107 return e.tokenFromCmd(cmd, warn)
108 }108 }
109 109
110-// tokenFromCmd is the output of token.cmd, or "" when the file sets none.110+// tokenFromCmd is the output of token.cmd, or "" when the file sets none or
111+// when ERMITE_URL points somewhere else than the url of the file: the token
112+// the command fetches belongs to the server of the file, and must not be sent
113+// to another one.
111 func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {114 func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
112 f, path, err := e.readFile(cmd, warn)115 f, path, err := e.readFile(cmd, warn)
113 if err != nil {116 if err != nil {
@@ -117,10 +120,33 @@ func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
117 if line == "" {120 if line == "" {
118 return "", nil121 return "", nil
119 }122 }
120- e.tokenOnce.Do(func() { e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line) })123+ e.tokenOnce.Do(func() {
124+ if !e.urlIsFromFile(f) {
125+ if warn {
126+ noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken)
127+ }
128+ return
129+ }
130+ e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), path, line)
131+ })
121 return e.token, e.tokenErr132 return e.token, e.tokenErr
122 }133 }
123 134
135+// urlIsFromFile reports whether the server in use is the one of the config
136+// file: ERMITE_URL is unset, or names the same address as the file's url.
137+func (e *cliEnv) urlIsFromFile(f *cliFile) bool {
138+ envURL := strings.TrimSpace(e.getenv(client.EnvURL))
139+ if envURL == "" {
140+ return true
141+ }
142+ a, err := client.ParseURL(envURL)
143+ if err != nil {
144+ return false
145+ }
146+ b, err := client.ParseURL(strings.TrimSpace(f.URL))
147+ return err == nil && a.String() == b.String()
148+}
149+
124 // fetchToken runs token.cmd. Neither its output nor its error text reach the150 // fetchToken runs token.cmd. Neither its output nor its error text reach the
125 // caller's messages: the output is the secret.151 // caller's messages: the output is the secret.
126 func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) {152 func (e *cliEnv) fetchToken(ctx context.Context, path, line string) (string, error) {
M internal/app/cli_configfile_test.go +33 -2
@@ -85,15 +85,46 @@ func TestEnvironmentWinsOverConfigFile(t *testing.T) {
8585 assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set")
8686 }
8787
88-func TestEnvURLAloneStillUsesTokenCmd(t *testing.T) {
88+func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) {
8989 f := listFake()
9090 env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})
9191 writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)
9292 ran := tokenCmdStub(env, "ermite_cmd", nil)
9393
94- runCLIWith(t, env, "", "repo", "list").ok(t)
94+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
95+ assert.Empty(t, *ran, "the token of the file's server must not go to another server")
96+ assert.Empty(t, lastAuth(f))
97+ assert.Equal(t, 1, strings.Count(res.stderr, "ermite: ERMITE_URL differs from the config file url: token.cmd not run (set ERMITE_TOKEN)\n"), res.stderr)
98+
99+ t.Run("file without url", func(t *testing.T) {
100+ env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})
101+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
102+ ran := tokenCmdStub(env, "ermite_cmd", nil)
103+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
104+ assert.Empty(t, *ran)
105+ assert.Contains(t, res.stderr, "token.cmd not run")
106+ })
107+ t.Run("invalid ERMITE_URL", func(t *testing.T) {
108+ env := testEnv(t, map[string]string{"ERMITE_URL": "nope"})
109+ writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)
110+ ran := tokenCmdStub(env, "ermite_cmd", nil)
111+ require.Error(t, runCLIWith(t, env, "", "repo", "list").err)
112+ assert.Empty(t, *ran)
113+ })
114+}
115+
116+func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) {
117+ f := listFake()
118+ url := f.serve(t)
119+ // the same address, written differently
120+ env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "})
121+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
122+ ran := tokenCmdStub(env, "ermite_cmd", nil)
123+
124+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
95125 assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
96126 assert.Len(t, *ran, 1)
127+ assert.NotContains(t, res.stderr, "token.cmd")
97128 }
98129
99130 func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) {
@@ -85,15 +85,46 @@ func TestEnvironmentWinsOverConfigFile(t *testing.T) {
85 assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set")85 assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set")
86 }86 }
87 87
88-func TestEnvURLAloneStillUsesTokenCmd(t *testing.T) {88+func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) {
89 f := listFake()89 f := listFake()
90 env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})90 env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})
91 writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)91 writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)
92 ran := tokenCmdStub(env, "ermite_cmd", nil)92 ran := tokenCmdStub(env, "ermite_cmd", nil)
93 93
94- runCLIWith(t, env, "", "repo", "list").ok(t)94+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
95+ assert.Empty(t, *ran, "the token of the file's server must not go to another server")
96+ assert.Empty(t, lastAuth(f))
97+ assert.Equal(t, 1, strings.Count(res.stderr, "ermite: ERMITE_URL differs from the config file url: token.cmd not run (set ERMITE_TOKEN)\n"), res.stderr)
98+
99+ t.Run("file without url", func(t *testing.T) {
100+ env := testEnv(t, map[string]string{"ERMITE_URL": f.serve(t)})
101+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
102+ ran := tokenCmdStub(env, "ermite_cmd", nil)
103+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
104+ assert.Empty(t, *ran)
105+ assert.Contains(t, res.stderr, "token.cmd not run")
106+ })
107+ t.Run("invalid ERMITE_URL", func(t *testing.T) {
108+ env := testEnv(t, map[string]string{"ERMITE_URL": "nope"})
109+ writeConfigFile(t, env, "url = http://file.invalid\ntoken.cmd = get-token\n", 0o600)
110+ ran := tokenCmdStub(env, "ermite_cmd", nil)
111+ require.Error(t, runCLIWith(t, env, "", "repo", "list").err)
112+ assert.Empty(t, *ran)
113+ })
114+}
115+
116+func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) {
117+ f := listFake()
118+ url := f.serve(t)
119+ // the same address, written differently
120+ env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "})
121+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
122+ ran := tokenCmdStub(env, "ermite_cmd", nil)
123+
124+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
95 assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))125 assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
96 assert.Len(t, *ran, 1)126 assert.Len(t, *ran, 1)
127+ assert.NotContains(t, res.stderr, "token.cmd")
97 }128 }
98 129
99 func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) {130 func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) {
M skills/ermite/CLI.md +1 -1
@@ -1,6 +1,6 @@
11 # ermite CLI reference
22
3-Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, or the file named by `ERMITE_CONFIG`), the environment first. Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields.
3+Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, or the file named by `ERMITE_CONFIG`), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields.
44
55 Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server.
66
@@ -1,6 +1,6 @@
1 # ermite CLI reference1 # ermite CLI reference
2 2
3-Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, or the file named by `ERMITE_CONFIG`), the environment first. Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields.3+Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, or the file named by `ERMITE_CONFIG`), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields.
4 4
5 Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server.5 Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server.
6 6

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close