From 89fcd6f83000a70e53cb7bfb0b52d733b3450b36 Mon Sep 17 00:00:00 2001 From: shemishtamesh <62944862+shemishtamesh@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:24:47 +0300 Subject: [PATCH] feat(config): add cobra-probe-enabled toggle option and safeguard cobra probing (#133) currently, every command that doesn't have a completion spec is assumed to be a cobra cli and it gets ran with a `__complete` argument. for non cobra clis this can cause problems if those non-cobra clis have sideeffects even when ran with the `__complete` argument, and there is no way to disable this. one of these problems was solved at #111, but a full solution would probably require actual sandboxing which is i think is an overkill just for attempting to get suggestions for unrecognized commands. instead of sandboxing, this pr adds a config option for allowing only selected commands to be probed and disallowing the rest. currently the default is still allowing all (`["*"]`) so default behavior is the same, but it might be better to just have a long list of known cobra clis as the default instead. here's a demo of a side effect caused by the probing, in this case "deleting" a file with rmtrash without actually trying to run the command: https://github.com/user-attachments/assets/5256d363-5291-42e8-bb4f-cf7467927246 --------- Co-authored-by: shemishtamesh --- README.md | 19 ++++++++-------- internal/config/config.go | 5 +++-- internal/config/config_test.go | 3 +++ internal/config/defaults.go | 15 +++++++------ root/config_cmd.go | 3 +++ spec/cobra_complete.go | 31 +++++++++++++++++++++++++ spec/cobra_complete_test.go | 36 ++++++++++++++++++++++++++++++ spec/testdata/cobrafixture/main.go | 9 ++++++++ 8 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 spec/testdata/cobrafixture/main.go diff --git a/README.md b/README.md index f27f942..424e2cb 100644 --- a/README.md +++ b/README.md @@ -292,15 +292,16 @@ iris config show ```toml [core] -version = 1 # config schema version -shell = "" # "zsh", "bash", "fish", or empty for auto-detect -shell-login = false # run shell as a login shell (also: iris --shell-login) -mode = "last" # "last", "spec", or "history" -debug = false # verbose logging to iris.log (also: iris -d) -expand-alias = true # expand aliases before matching -auto-execute = false # run suggestion immediately instead of inserting it -atuin-history = 0 # 0 = shell history, 1 = atuin, 2 = both -atuin-db-path = "" # path to atuin's history.db, empty = use default +version = 1 # config schema version +shell = "" # "zsh", "bash", "fish", or empty for auto-detect +shell-login = false # run shell as a login shell (also: iris --shell-login) +mode = "last" # "last", "spec", or "history" +debug = false # verbose logging to iris.log (also: iris -d) +expand-alias = true # expand aliases before matching +auto-execute = false # run suggestion immediately instead of inserting it +atuin-history = 0 # 0 = shell history, 1 = atuin, 2 = both +atuin-db-path = "" # path to atuin's history.db, empty = use default +cobra-probe-enabled = true # fall back to probing cobra binaries for completions [ui] style = "modern" # "modern" or "classic" diff --git a/internal/config/config.go b/internal/config/config.go index afcd78d..d3b91d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,8 +41,9 @@ type CoreConfig struct { ExpandAlias bool `toml:"expand-alias"` AutoExecute bool `toml:"auto-execute"` // 0 = shell history, 1 = atuin only, 2 = atuin + shell - Atuin int `toml:"atuin-history"` - AtuinDBPath string `toml:"atuin-db-path"` + Atuin int `toml:"atuin-history"` + AtuinDBPath string `toml:"atuin-db-path"` + CobraProbeEnabled bool `toml:"cobra-probe-enabled"` } type UIConfig struct { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 988eb1c..4e9c1f1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -27,6 +27,9 @@ func TestDefaultConfigAndState(t *testing.T) { if cfg.AI.Providers != nil { t.Errorf("expected default providers map to be nil, got %v", cfg.AI.Providers) } + if !cfg.Core.CobraProbeEnabled { + t.Errorf("expected cobra probing to be enabled by default") + } // test manual provider registration cfg.AI.Provider = "custom" diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 9685c96..695c3e4 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -7,13 +7,14 @@ import ( func DefaultConfig() *Config { return &Config{ Core: CoreConfig{ - Version: 1, - Shell: "", - ShellLogin: false, - Mode: "last", - Debug: false, - ExpandAlias: true, - AutoExecute: false, + Version: 1, + Shell: "", + ShellLogin: false, + Mode: "last", + Debug: false, + ExpandAlias: true, + AutoExecute: false, + CobraProbeEnabled: true, }, UI: UIConfig{ Style: "modern", diff --git a/root/config_cmd.go b/root/config_cmd.go index 27d0620..11308b7 100644 --- a/root/config_cmd.go +++ b/root/config_cmd.go @@ -65,6 +65,9 @@ atuin-history = 0 # custom atuin database path (leave empty for default) atuin-db-path = "" +# probe unknown binaries with ` + "`__complete`" + ` for Cobra-based CLI suggestions. +cobra-probe-enabled = true + [ui] # visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons) style = "modern" diff --git a/spec/cobra_complete.go b/spec/cobra_complete.go index 86afdc6..940bc7c 100644 --- a/spec/cobra_complete.go +++ b/spec/cobra_complete.go @@ -2,6 +2,7 @@ package spec import ( "context" + "debug/buildinfo" "os" "os/exec" "strconv" @@ -9,8 +10,12 @@ import ( "sync" "syscall" "time" + + "github.com/versenilvis/iris/internal/config" ) +const cobraModulePath = "github.com/spf13/cobra" + type cobraCacheEntry struct { suggestions []Suggestion } @@ -91,6 +96,25 @@ func buildCobraCacheKey(binKey string, args []string, partial string) string { return sb.String() } +// isLikelyCobraBinary reports whether binName is a Go binary linking Cobra. +// only does static analysis so can produce false negatives and positives. +func isLikelyCobraBinary(binName string) bool { + path, err := exec.LookPath(binName) + if err != nil { + return false + } + info, err := buildinfo.ReadFile(path) + if err != nil { + return false + } + for _, dep := range info.Deps { + if dep.Path == cobraModulePath { + return true + } + } + return false +} + // newProbeCmd builds and isolates the `__complete` probe command. // starts the child in its own session so it has no controlling terminal // and therefore won't affect the user's tty in the case of programs that @@ -108,6 +132,9 @@ func QueryCobraComplete(binName string, args []string, partial string) []Suggest if strings.ContainsAny(binName, `/\`) { return nil } + if !config.Get().Core.CobraProbeEnabled { + return nil + } binKey := cobraBinKey(binName) argKey := buildCobraCacheKey(binKey, args, partial) @@ -119,6 +146,10 @@ func QueryCobraComplete(binName string, args []string, partial string) []Suggest } cobraCacheMu.Unlock() + if !isLikelyCobraBinary(binName) { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() diff --git a/spec/cobra_complete_test.go b/spec/cobra_complete_test.go index 1f21602..8749bf8 100644 --- a/spec/cobra_complete_test.go +++ b/spec/cobra_complete_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "testing" "time" + + "github.com/versenilvis/iris/internal/config" ) func TestParseCobraOutput_ValidCobra(t *testing.T) { @@ -166,6 +168,40 @@ func TestNewProbeCmd_NoControllingTerminal(t *testing.T) { } } +func TestQueryCobraComplete_ProbeDisabled(t *testing.T) { + t.Cleanup(ResetCobraCache) + original := config.Get() + t.Cleanup(func() { config.Init(original) }) + + cfg := config.DefaultConfig() + cfg.Core.CobraProbeEnabled = false + config.Init(cfg) + + if result := QueryCobraComplete("non-go-binary", nil, ""); result != nil { + t.Errorf("expected nil when cobra probing is disabled, got %v", result) + } +} + +func TestLooksLikeCobraBinary_NotGoBinary(t *testing.T) { + // 'ls' is a standard unix command that's not a go binary + if isLikelyCobraBinary("ls") { + t.Errorf("expected 'ls' to not look like a Cobra binary") + } +} + +func TestLooksLikeCobraBinary_RealCobraBinary(t *testing.T) { + dir := t.TempDir() + binPath := filepath.Join(dir, "cobrafixture") + build := exec.CommandContext(context.Background(), "go", "build", "-o", binPath, "./testdata/cobrafixture") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("could not build fixture binary: %v: %s", err, out) + } + + if !isLikelyCobraBinary(binPath) { + t.Errorf("expected fixture binary linking Cobra to look like a Cobra binary") + } +} + func TestLookup_CobraGolangciLint(t *testing.T) { results := Lookup("golangci-lint ") if len(results) == 0 { diff --git a/spec/testdata/cobrafixture/main.go b/spec/testdata/cobrafixture/main.go new file mode 100644 index 0000000..c2f8a64 --- /dev/null +++ b/spec/testdata/cobrafixture/main.go @@ -0,0 +1,9 @@ +package main + +import "github.com/spf13/cobra" + +// minimal Cobra CLI, built at test time as a fixture for TestLooksLikeCobraBinary_RealCobraBinary +func main() { + root := &cobra.Command{Use: "cobrafixture"} + _ = root.Execute() +}