Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Tags:
  • go
  • os-exec
  • path
  • security
  • errdot
  • debugging

Go's exec.ErrDot: Why exec.Command Won't Run a Binary From PATH's Dot

The title I was given for this one was "why exec.Command refuses to run ./tool", and I should correct that first, because the real behaviour is a bit more interesting. exec.Command("./tool") works fine. What breaks is exec.Command("tool") when the only tool your PATH can find lives in the current directory. Those two look similar and take completely different code paths.

The error

Set up a directory containing an executable called tool, and put . on your PATH (or, more commonly, an empty entry, which POSIX shells treat as the current directory too):

package main

import (
	"errors"
	"fmt"
	"os/exec"
)

func main() {
	err := exec.Command("tool").Run()
	fmt.Println(err)
	fmt.Println(errors.Is(err, exec.ErrDot))
}
$ PATH=".:$PATH" go run .
exec: "tool": cannot run executable found relative to current directory
true

That arrived in Go 1.19 (see the release notes). Before it, Go would happily run ./tool because your PATH said to look in .. Now LookPath still finds it but returns the path together with an error wrapping exec.ErrDot, and Command stores that in cmd.Err so Run and Start fail without executing anything.

Why the slash matters

The lookup only happens if the name contains no path separator. exec.Command("./tool") or exec.Command("/opt/x/tool") skips PATH entirely; you named a location, so Go uses it. That distinction is the whole design: ErrDot is not "Go dislikes the current directory", it is "Go dislikes the current directory being chosen for you by a search".

Here is the attack it addresses. You clone a repository, or unpack an archive, or cd into a directory somebody else controls. Some program you run calls exec.Command("git"). If . is on PATH ahead of the real git, a file named git in that directory runs with your privileges. The caller never asked for that, and usually has no idea PATH contains a relative entry, since it is an environment property, not something in the code.

Windows was the worse case. The old Windows search order included the current directory implicitly, whatever PATH said, so the same trap existed for every user without them having done anything odd. Go 1.19 closed that as well; on Windows the current directory is no longer searched unless you ask for it by name.

What counts as "relative"

Actually, this bit is worth being precise about. The check is on the result of the search, not on the literal PATH string. Any PATH entry that is not an absolute path can trigger it: ., an empty entry, or something like bin or ../tools. All of these resolve against whatever the working directory is at the moment of the call, which is exactly the property that makes them dangerous, and it also means the same program can behave differently after a os.Chdir.

You can see the result without running anything:

path, err := exec.LookPath("tool")
if errors.Is(err, exec.ErrDot) {
	fmt.Println("found, but relative:", path)
}

Note that path is populated alongside the error. That is deliberate, and it is what lets you make an informed decision.

Fixes, best first

Fix your PATH. If . or an empty entry is in there, remove it. A trailing colon in a hand-built PATH (PATH=$PATH:) is the usual accidental cause. Shells and language runtimes that quietly obey it are the underlying problem; Go is just the one that started saying so.

Be explicit when you mean a local file. If the program is a helper shipped next to your binary or in a known directory, say so with a path, ideally an absolute one:

exe, err := os.Executable()
if err != nil {
	return err
}
helper := filepath.Join(filepath.Dir(exe), "tool")
cmd := exec.Command(helper)

That is better than "./tool", which depends on the working directory and is the same class of problem in a different coat. It merely fails closed instead of open (no file, no run) rather than quietly running whatever is there.

Opt in for a specific command. The package documentation gives the sanctioned escape hatch for when you really do want the PATH-resolved relative result:

cmd := exec.Command("tool")
if errors.Is(cmd.Err, exec.ErrDot) {
	cmd.Err = nil
}
if err := cmd.Run(); err != nil {
	return err
}

Clearing cmd.Err is a per-call decision, visible in the code review, which is the point. If the name comes from user input or a config file, think hard before writing this; you are re-creating the vulnerability on purpose. Checking the returned path from LookPath against a directory you trust first is safer than a blanket clear.

The global switch. Go 1.19 also shipped a GODEBUG setting, execerrdot=0, that restores the old behaviour for the whole process. Treat it as a migration aid for a build that broke and needs to ship on Friday, not a destination. Check the current os/exec documentation and the GODEBUG history page for whether it still applies to your toolchain version, since these settings are eventually retired.

Diagnosing a build that started failing

The typical report is "worked on Go 1.18, fails after upgrade, only in CI" or "only on the new laptop". The error text is specific, so grep for it. Then print os.Getenv("PATH") and look for an empty segment (::, a leading or trailing colon) or a dot. Docker images and CI runners that build PATH by string concatenation are frequent offenders, particularly when a variable was unset and left an empty entry behind.

One more thing to be aware of: exec.CommandContext behaves identically here, and so does anything built on LookPath, including third-party wrappers. If a library fails with this error, the fix is still upstream in your PATH or in how the library is called, not something to patch inside it.

The slightly uncomfortable observation is that the shell spent decades letting people put . on PATH and telling them not to. Go just declined to be the program that quietly went along with it.