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
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,26 @@ go vet ./... # vet
- `internal/output/output.go` — colored/emoji output with `--no-color` flag support
- `internal/output/output_test.go` — ANSI stripping verification
- `internal/args/args.go` — argument validation (enum, glob, regex)
- `internal/trust/trust.go` — direnv-style trust store; content-hashed gate over the local config

Tests: `cmd/root_test.go`, `internal/{config,checker,version,output,args}/*_test.go`
Tests: `cmd/root_test.go`, `internal/{config,checker,version,output,args,trust}/*_test.go`

## Working conventions

- Verbs are defined in YAML, not hardcoded. Adding a new verb means editing config, not code.
- Config schema:
- `shell_options: "set -euo pipefail"` — prepended to all shell scripts (multiline `cmd` and all `cmds` items)
- `shell_options: "set -euo pipefail"` — prepended to all shell scripts (every `cmd` and all `cmds` items)
- `commands.<verb>.{cmd, cmds, env, description, arguments[]}` — `cmd` is a string, `cmds` is a list of strings, `env` is a map of environment variables; arguments are objects with `name`, optional `values` (enum), optional `match` (glob or regex), optional `exclude` (list of disallowed values)
- `tools.<binary>.{min_version, max_version, version_cmd, download_url}` — pre-flight checks run before every verb
- Arguments after the verb are mapped positionally to `arguments` entries and expanded into `${name}` placeholders in `cmd` or `cmds`
- `cmd` supports multiline YAML block scalars (`|`): if single-line, runs as a command; if multi-line, runs as a shell script via `sh -c`
- `cmd` runs via `sh -c` (single-line and multiline block scalars alike), so quoting, pipes, and shell operators work; multiline blocks run as a shell script
- `cmds` is a list of commands; each item runs via `sh -c`, so shell features (variables, subshells, pipes) work; multi-line items also run as shell scripts
- `env` sets environment variables for the command execution; merged with current environment
- `match` is auto-detected: contains `*` or `?` → glob (checks files on disk); otherwise → regex (full string match, auto-anchored)
- `match` is auto-detected: contains `*` or `?` → glob (checks files on disk); otherwise → regex (full string match, anchored as `^(?:pattern)$` so top-level alternation stays bound)
- Glob matching accepts full path, basename, basename without extension, or directory name
- `exclude` filters out values from glob matches and rejects them during validation; excluded values are hidden from help output
- `ugo check` runs tool checks and prints status for each tool
- Version comparison uses `golang.org/x/mod/semver`; `version_cmd` output is scanned for a semver pattern
- Running a verb without required arguments (or with invalid args) prints the error then the help, then exits
- Security model: config (global + local-from-CWD) and argument/prompt values are trusted; `cmd`/`cmds` run via `sh -c` and `${name}` values are expanded as unquoted shell text. Constrain untrusted args with `values`/`match`. Sensitive prompt values are masked in uGo's output only — they still reach the shell (visible in `ps`, and to `set -x`). See README "Security".
- Trust gate: the local (CWD) config must be trusted before any verb or `check` executes. Trust is content-hashed (path + SHA-256) in `~/.config/<binary>/trust.json`; editing the config revokes it. `PersistentPreRunE` prompts on a TTY; `--trust` records trust without prompting (CI/CD); non-interactive + untrusted aborts. `help`/`version` are never gated. The global config is implicitly trusted.
67 changes: 64 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ uGo loads configuration from two locations and merges them (local overrides glob

### Shell options

Set `shell_options` to prepend shell flags to all commands that run via `sh -c` (multiline `cmd` and all `cmds` items):
Set `shell_options` to prepend shell flags to all commands that run via `sh -c` (every `cmd` — single-line and multiline — and all `cmds` items):

```yaml
shell_options: "set -euo pipefail"
Expand Down Expand Up @@ -119,7 +119,7 @@ tools:
```yaml
commands:
<verb>:
cmd: "<single command with ${arg} templates>" # string: runs directly (single-line) or as shell script (multi-line)
cmd: "<single command with ${arg} templates>" # string: runs via "sh -c", so quoting, pipes, and && work
cmds: # list: each item runs via sh -c (supports shell features)
- "echo ${arg}"
- |
Expand Down Expand Up @@ -216,7 +216,16 @@ Arguments support three validation modes:

Glob vs regex is auto-detected: if the pattern contains `*` or `?` it's treated as a file glob.

### Multiline commands
### Command execution

Every `cmd` runs via `sh -c`, so shell features — quoting, embedded whitespace, pipes (`|`), and operators (`&&`, `||`, redirects) — behave as written:

```yaml
commands:
plan:
cmd: terraform workspace select "${workspace}" && terraform plan
description: "Select a workspace and plan"
```

**`cmd` with multiline** — runs the entire block as a shell script via `sh -c`:

Expand Down Expand Up @@ -335,6 +344,58 @@ $ ugo check
❌ Tool checks failed
```

## Security

uGo's job is to run commands you have configured, so **the configuration and the argument values you pass are trusted inputs**. A few properties are worth understanding:

### Configuration is loaded from the working directory

uGo reads `./<binary>.yaml` from the current directory and merges it over your global config. Running a verb — or `ugo check` — executes the `version_cmd` of each configured tool found on your `$PATH`, and runs the verb's `cmd`/`cmds` via `sh -c`. Changing into a directory and running a verb therefore runs *that directory's* configuration, the same way `make`, `npm run`, or a `./go` script would.

To guard against running an unfamiliar repository's config, uGo gates the **local** config behind a trust prompt (see [Trusting a directory](#trusting-a-directory)). The global config (`~/.config/<binary>/config.yaml`) is user-owned and always trusted. `ugo --help` and `ugo version` never execute anything from the config, so they are safe to run anywhere.

### Trusting a directory

The first time you run a verb (or `ugo check`) in a directory with a local config, uGo asks before executing anything:

```bash
$ ugo build

⚠️ /home/me/project/ugo.yaml is not trusted.
Running a verb here will execute the commands defined in this file.
Trust it? [y/N]:
```

Answering `y` records the config as trusted and runs it; anything else aborts without executing. Trust is **content-addressed**: uGo stores the path together with a SHA-256 of the file's contents in `~/.config/<binary>/trust.json`. If the config is later edited (e.g. a `git pull` changes it), trust is automatically revoked and you are prompted again.

For non-interactive use (CI/CD), pass `--trust` to skip the prompt and record the config as trusted:

```bash
ugo --trust build
```

When no terminal is attached and `--trust` is not given, uGo refuses to run rather than executing an untrusted config silently. To revoke trust, delete the relevant entry (or the whole file) from `~/.config/<binary>/trust.json`.

### Argument and prompt values are expanded as shell text

`${name}` placeholders are substituted into the command string *before* it is handed to `sh -c`, and the values are **not** shell-quoted. A value like `foo; rm -rf ~` placed in an unconstrained argument runs as written.

If a verb can receive values from an untrusted or external source (CI variables, webhooks, etc.), constrain those arguments:

- `values: [...]` — accept only an explicit set (exact match), or
- `match: "<regex-or-glob>"` — validate against a fully-anchored regex, or an on-disk glob.

Arguments with neither are accepted verbatim. Unresolved `${name}` placeholders (a typo, or a deliberate `$HOME`) are passed through and expanded by the shell.

### Secrets

`sensitive: true` masks a prompt's value **in uGo's own output only** — the `🚀` line shows `********`. The real value is still expanded into the command, which means:

- it is passed to `sh -c`, so it can be visible in the process list (`ps`, `/proc`) to other local users while the command runs; and
- enabling shell tracing through `shell_options` (e.g. `set -x`) echoes the expanded command — including the secret — to stderr.

Prefer passing secrets through the environment — reference a `${prompt}` from an `env:` value and use `"$VAR"` in the command, rather than interpolating the secret directly into `cmd` — and avoid `set -x` when handling sensitive prompts.

## Colored Output

uGo uses UTF-8 icons and colors for status output. Use `--no-color` to disable:
Expand Down
165 changes: 128 additions & 37 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ package cmd
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"

"github.com/spf13/cobra"
"github.com/PyratLabs/ugo/internal/args"
"github.com/PyratLabs/ugo/internal/checker"
"github.com/PyratLabs/ugo/internal/config"
"github.com/PyratLabs/ugo/internal/output"
"github.com/PyratLabs/ugo/internal/trust"
"golang.org/x/term"
)

Expand All @@ -21,6 +25,7 @@ var (
binaryName string
appCfg *config.Config
noColor bool
trustFlag bool
)

func RootCmd() *cobra.Command {
Expand Down Expand Up @@ -49,14 +54,26 @@ Local config overrides global config for the same verb names.`,
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
output.SetNoColor(noColor)
if cmd.Name() == "check" || cmd.Name() == "help" || cmd.Name() == "version" {
// help and version never execute anything from the config.
if cmd.Name() == "help" || cmd.Name() == "version" {
return nil
}
// Everything else (verbs and check) can run config-defined
// commands, so the local config must be trusted first.
if err := enforceTrust(); err != nil {
output.CheckFail(err.Error())
os.Exit(1)
}
// check does its own tool checking in its Run.
if cmd.Name() == "check" {
return nil
}
return runToolChecks()
},
}

root.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable color output")
root.PersistentFlags().BoolVar(&trustFlag, "trust", false, "trust this directory's config without prompting (for CI/CD)")
root.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
return fmt.Errorf("unknown flag: %s\nRun '%s help' for usage", err.Error(), binaryName)
})
Expand Down Expand Up @@ -116,7 +133,13 @@ func printToolStatus(tools map[string]config.Tool, issues []checker.Issue) {
issueMap[i.Tool] = i
}

names := make([]string, 0, len(tools))
for name := range tools {
names = append(names, name)
}
sort.Strings(names)

for _, name := range names {
if issue, ok := issueMap[name]; ok {
for _, e := range issue.Errors {
if strings.HasPrefix(e, "version:") {
Expand Down Expand Up @@ -154,9 +177,91 @@ func runToolChecks() error {
return nil
}

// enforceTrust gates execution of config-defined commands behind the trust
// store, prompting on os.Stdin or honoring --trust. It is thin glue over
// trustGate so the latter stays free of globals and easy to test.
func enforceTrust() error {
_, localPath := config.ConfigPaths(binaryName)
storePath, err := trust.DefaultStorePath(binaryName)
if err != nil {
return err
}
interactive := term.IsTerminal(int(os.Stdin.Fd()))
return trustGate(localPath, storePath, bufio.NewReader(os.Stdin), os.Stderr, trustFlag, interactive)
}

// trustGate decides whether the local config may be executed. It returns nil to
// allow execution or an error explaining why it is blocked. allow corresponds
// to --trust; interactive reports whether prompting is possible.
func trustGate(localPath, storePath string, in *bufio.Reader, out io.Writer, allow, interactive bool) error {
// Only the working-directory config is gated; the global config is
// user-owned and implicitly trusted.
if !fileExists(localPath) {
return nil
}

absPath, err := filepath.Abs(localPath)
if err != nil {
return err
}
hash, err := trust.HashFile(localPath)
if err != nil {
return fmt.Errorf("reading %s: %w", localPath, err)
}

store, err := trust.Load(storePath)
if err != nil {
return fmt.Errorf("loading trust store: %w", err)
}

status := store.Status(absPath, hash)
if status == trust.Trusted {
return nil
}

// --trust: skip the prompt and record trust (best-effort, so a read-only
// home in CI/CD doesn't fail the run).
if allow {
if err := store.Trust(absPath, hash); err != nil {
fmt.Fprintf(out, " warning: could not record trust for %s: %v\n", absPath, err)
}
return nil
}

if !interactive {
return fmt.Errorf("%s is not trusted; re-run with --trust to allow it (e.g. in CI/CD)", localPath)
}

fmt.Fprintln(out)
if status == trust.Changed {
fmt.Fprintf(out, " ⚠️ %s has changed since it was last trusted.\n", absPath)
} else {
fmt.Fprintf(out, " ⚠️ %s is not trusted.\n", absPath)
}
fmt.Fprintln(out, " Running a verb here will execute the commands defined in this file.")
fmt.Fprint(out, " Trust it? [y/N]: ")

line, _ := in.ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
if err := store.Trust(absPath, hash); err != nil {
return fmt.Errorf("recording trust: %w", err)
}
fmt.Fprintf(out, " trusted %s\n\n", absPath)
return nil
default:
return fmt.Errorf("%s not trusted; aborting", localPath)
}
}

func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

func buildCommand(name string, def config.Command) *cobra.Command {
c := &cobra.Command{
Use: buildUse(name, def.Arguments, def.Prompts),
Use: buildUse(name, def.Arguments),
Short: def.Description,
Long: buildLong(def.Arguments, def.Prompts),
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -190,7 +295,7 @@ func buildLong(arguments []config.Argument, prompts []config.Prompt) string {
b.WriteString("(no files found)")
}
} else {
b.WriteString(fmt.Sprintf("^%s$", arg.Match))
b.WriteString(fmt.Sprintf("^(?:%s)$", arg.Match))
}
default:
b.WriteString("(no validation)")
Expand All @@ -217,7 +322,7 @@ func buildLong(arguments []config.Argument, prompts []config.Prompt) string {
return b.String()
}

func buildUse(name string, arguments []config.Argument, prompts []config.Prompt) string {
func buildUse(name string, arguments []config.Argument) string {
if len(arguments) == 0 {
return name
}
Expand Down Expand Up @@ -264,7 +369,10 @@ func executeCommand(cmd *cobra.Command, name string, def config.Command, values
}
}

// Prompt if no value from env var
// Prompt if there is still no value. Note a set-but-empty from_env_var
// (e.g. TOKEN="") intentionally falls through to the interactive
// prompt — this matches the documented "unset or empty" behavior, so
// an empty env var cannot be used to supply an empty answer.
if value == "" {
var err error
if p.Sensitive {
Expand Down Expand Up @@ -344,30 +452,26 @@ func executeCmdString(name, cmdStr string, vars map[string]string, env map[strin
displayVars := output.MaskedVars(vars, sensitiveNames)
display := expandVars(cmdStr, displayVars)

if strings.TrimSpace(expanded) == "" {
output.CommandSuccess(name)
return nil
}

// Single-line and multiline cmd both run via "sh -c" so that quoting,
// embedded whitespace, and shell operators (&&, |, redirects) behave as
// written rather than being split on whitespace into argv.
if strings.Contains(expanded, "\n") {
output.CommandRunning(name, "shell script")
if err := runShellScript(expanded, env, shellOpts); err != nil {
output.CommandFail(name)
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
os.Exit(1)
}
} else {
parts := strings.Fields(expanded)
if len(parts) == 0 {
output.CommandSuccess(name)
return nil
}

output.CommandRunning(name, display)
if err := runCommand(expanded, env); err != nil {
output.CommandFail(name)
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
os.Exit(1)
}

if err := runShellScript(expanded, env, shellOpts); err != nil {
output.CommandFail(name)
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
os.Exit(1)
}

output.CommandSuccess(name)
Expand Down Expand Up @@ -396,19 +500,6 @@ func expandEnv(env map[string]string, vars map[string]string) map[string]string
return expanded
}

func runCommand(cmdStr string, env map[string]string) error {
parts := strings.Fields(cmdStr)
if len(parts) == 0 {
return nil
}
command := exec.Command(parts[0], parts[1:]...)
command.Stdout = os.Stdout
command.Stderr = os.Stderr
command.Stdin = os.Stdin
applyEnv(command, env)
return command.Run()
}

func runShellScript(script string, env map[string]string, shellOpts string) error {
if shellOpts != "" {
script = shellOpts + "\n" + script
Expand Down
Loading
Loading