From d014412f11397d1e11ce6a257a4bf68a539469bd Mon Sep 17 00:00:00 2001 From: Tim Walsh Date: Tue, 22 Sep 2026 23:31:20 -0700 Subject: [PATCH 1/2] feat(extensions): dispatch cu to cu- on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the kubectl-style passthrough promised in PROJECT_SPEC §4.3 and specified in the context-layer design §3.6 — the subset that does not depend on refs, aliases or packs, so it can land on its own. Builtins always win: dispatch is attempted only for a word cobra itself could not resolve, so an extension can never shadow a cu command, and a shadowed extension is still runnable directly as cu-. There is no PATH scan at startup — a single exec.LookPath once a word is known to be unresolvable — so the normal path costs nothing. The command word is found by reading flag arity from the flag set rather than guessing, so `cu --config x worklog` dispatches to worklog and not to x. Everything after the word is forwarded verbatim, including flags that look like cu's own; cu's own preceding flags are not. The child inherits CU_CONFIG_DIR, CU_WORKSPACE and CU_PROJECT_CONFIG, and its exit status is propagated unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014aqbmccWm1tqttmBUCR5rv ClickUp: 86dxbeqyt --- README.md | 15 +++ internal/cmd/extension.go | 164 +++++++++++++++++++++++++++++++++ internal/cmd/extension_test.go | 87 +++++++++++++++++ internal/cmd/root.go | 3 + 4 files changed, 269 insertions(+) create mode 100644 internal/cmd/extension.go create mode 100644 internal/cmd/extension_test.go diff --git a/README.md b/README.md index c027f97..0fec54e 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,21 @@ cu api /list/abc123/task -X POST -d '{"name": "New Task"}' cu api "/list/abc123/task?archived=false" ``` +## Extensions + +Any executable named `cu-` on your `PATH` becomes `cu `, the same +convention `kubectl` and `gh` use: + +```bash +# an executable called cu-worklog on PATH +cu worklog plan --since 1d # runs: cu-worklog plan --since 1d +``` + +Builtins always win, so an extension can never shadow a cu command — a shadowed +extension stays runnable directly as `cu-`. The child inherits +`CU_CONFIG_DIR`, `CU_WORKSPACE` and `CU_PROJECT_CONFIG` so it does not have to +rediscover cu's context, and its exit code is passed through unchanged. + ## Documentation Full documentation is available at [https://timimsms.github.io/cu/](https://timimsms.github.io/cu/) diff --git a/internal/cmd/extension.go b/internal/cmd/extension.go new file mode 100644 index 0000000..7375b50 --- /dev/null +++ b/internal/cmd/extension.go @@ -0,0 +1,164 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/pflag" + "github.com/timimsms/cu/internal/config" +) + +// extensionPrefix is the naming convention for external subcommands: +// `cu worklog` runs `cu-worklog` from PATH, the same shape kubectl and gh use. +const extensionPrefix = "cu-" + +// tryExtension runs an external subcommand when cobra cannot resolve the word, +// and never returns if it does. It is deliberately the *last* resort: builtins +// always win, so an extension can never shadow a cu command. +// +// There is no PATH scan at startup — one exec.LookPath only once a word is +// known to be unresolvable — so this costs nothing on the normal path. +func tryExtension(args []string) { + if len(args) == 0 { + return + } + + // Only consider a word cobra itself could not resolve. + if _, _, err := rootCmd.Find(args); err == nil { + return + } + + word := firstCommandWord(args, rootCmd.PersistentFlags()) + if word == "" || strings.HasPrefix(word, extensionPrefix) { + return + } + + path, err := exec.LookPath(extensionPrefix + word) + if err != nil { + // No such extension: fall through so cobra reports the unknown + // command with its did-you-mean suggestions. + return + } + + runExtension(path, word, args) +} + +// runExtension execs the extension and exits with its status. It does not +// return. +func runExtension(path, word string, args []string) { + // Pass every argument after the extension word through verbatim, including + // flags that look like cu's own — they belong to the extension. + rest := argsAfter(args, word) + + cmd := exec.Command(path, rest...) // #nosec G204 - path comes from exec.LookPath, args are the user's own + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = extensionEnv(args) + + err := cmd.Run() + if err == nil { + os.Exit(0) + } + + // Propagate the child's exit status; a cu-shaped failure code from an + // extension should reach the caller's shell unchanged. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + + fmt.Fprintf(os.Stderr, "failed to run %s: %v\n", filepath.Base(path), err) + os.Exit(1) +} + +// extensionEnv hands the child the context it would otherwise have to +// rediscover: which config cu resolved, and which workspace and project file +// are in play. +func extensionEnv(args []string) []string { + // The extension runs before PersistentPreRunE, so load config here. A + // failure is not fatal — the extension simply gets less context. + _ = config.Init(configFileFrom(args)) + + env := append(os.Environ(), + "CU_CONFIG_DIR="+filepath.Dir(config.GlobalConfigPath()), + "CU_WORKSPACE="+config.GetString("default_workspace"), + "CU_PROJECT_CONFIG="+config.GetProjectConfigPath(), + ) + return env +} + +// configFileFrom extracts a --config value so the child sees the same config +// cu would have used. +func configFileFrom(args []string) string { + for i := 0; i < len(args); i++ { + a := args[i] + if v, ok := strings.CutPrefix(a, "--config="); ok { + return v + } + if a == "--config" && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +// argsAfter returns everything following the extension word, so cu's own +// global flags that preceded it are not forwarded. +func argsAfter(args []string, word string) []string { + for i, a := range args { + if a == word { + return args[i+1:] + } + } + return nil +} + +// firstCommandWord returns the first positional argument — the candidate +// command — skipping global flags and the values they consume. Getting this +// wrong would treat a flag's value as a command name, so flag arity is read +// from the flag set rather than guessed. +func firstCommandWord(args []string, flags *pflag.FlagSet) string { + for i := 0; i < len(args); i++ { + a := args[i] + + switch { + case a == "--": + return "" + + case strings.HasPrefix(a, "--"): + if strings.Contains(a, "=") { + continue // --flag=value consumes nothing further + } + name := strings.TrimPrefix(a, "--") + if takesValue(flags.Lookup(name)) { + i++ + } + + case strings.HasPrefix(a, "-") && len(a) > 1: + if strings.Contains(a, "=") { + continue + } + // In a shorthand cluster only the final flag can take a value. + last := a[len(a)-1:] + if takesValue(flags.ShorthandLookup(last)) { + i++ + } + + default: + return a + } + } + return "" +} + +// takesValue reports whether a flag consumes the following argument. An +// unknown flag is assumed not to, so an unrecognised flag cannot swallow the +// command word. +func takesValue(f *pflag.Flag) bool { + return f != nil && f.Value.Type() != "bool" +} diff --git a/internal/cmd/extension_test.go b/internal/cmd/extension_test.go new file mode 100644 index 0000000..d7b8adf --- /dev/null +++ b/internal/cmd/extension_test.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" +) + +func testFlags() *pflag.FlagSet { + fs := pflag.NewFlagSet("cu", pflag.ContinueOnError) + fs.String("config", "", "") + fs.Bool("debug", false, "") + fs.StringP("output", "o", "table", "") + return fs +} + +func TestFirstCommandWord(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"bare word", []string{"worklog", "plan"}, "worklog"}, + {"no args", nil, ""}, + {"only flags", []string{"--debug"}, ""}, + + // A value-taking flag must not have its value mistaken for the + // command — that would exec cu-x instead of cu-worklog. + {"long flag with value", []string{"--config", "x", "worklog"}, "worklog"}, + {"long flag with equals", []string{"--config=x", "worklog"}, "worklog"}, + {"shorthand with value", []string{"-o", "json", "worklog"}, "worklog"}, + {"shorthand with equals", []string{"-o=json", "worklog"}, "worklog"}, + + // Boolean flags consume nothing. + {"bool flag", []string{"--debug", "worklog"}, "worklog"}, + {"bool then valued", []string{"--debug", "--config", "x", "worklog"}, "worklog"}, + + // An unknown flag is assumed not to take a value, so it cannot + // swallow the command word. + {"unknown flag", []string{"--mystery", "worklog"}, "worklog"}, + + {"double dash ends the search", []string{"--", "worklog"}, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, firstCommandWord(tc.args, testFlags())) + }) + } +} + +func TestArgsAfter(t *testing.T) { + t.Run("forwards everything after the word", func(t *testing.T) { + got := argsAfter([]string{"worklog", "plan", "--since", "1d"}, "worklog") + assert.Equal(t, []string{"plan", "--since", "1d"}, got) + }) + + t.Run("cu's own preceding flags are not forwarded", func(t *testing.T) { + got := argsAfter([]string{"--output", "json", "worklog", "plan"}, "worklog") + assert.Equal(t, []string{"plan"}, got) + }) + + t.Run("word with no trailing args", func(t *testing.T) { + assert.Empty(t, argsAfter([]string{"worklog"}, "worklog")) + }) + + t.Run("absent word", func(t *testing.T) { + assert.Nil(t, argsAfter([]string{"task", "list"}, "worklog")) + }) +} + +func TestConfigFileFrom(t *testing.T) { + assert.Equal(t, "/tmp/c.yaml", configFileFrom([]string{"--config", "/tmp/c.yaml", "worklog"})) + assert.Equal(t, "/tmp/c.yaml", configFileFrom([]string{"--config=/tmp/c.yaml", "worklog"})) + assert.Equal(t, "", configFileFrom([]string{"worklog", "plan"})) + assert.Equal(t, "", configFileFrom([]string{"--config"}), "a dangling --config must not panic") +} + +func TestTryExtensionLeavesBuiltinsAlone(t *testing.T) { + // tryExtension exits the process when it dispatches, so the safe + // assertion is that a resolvable builtin returns normally — proving + // builtins are never routed to an extension. + tryExtension([]string{"version"}) + tryExtension([]string{"task", "list"}) + tryExtension([]string{}) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index e1738df..49ae424 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -40,6 +40,9 @@ enabling efficient task management and seamless integration with development wor // Execute adds all child commands to the root command and sets flags appropriately. func Execute() error { + // Builtins first: tryExtension only acts on a word cobra cannot resolve, + // and does not return when it finds one. + tryExtension(os.Args[1:]) return rootCmd.Execute() } From a3c79f04defe5e4812233fd24a01451eb49a6a7f Mon Sep 17 00:00:00 2001 From: Tim Walsh Date: Tue, 22 Sep 2026 23:37:02 -0700 Subject: [PATCH 2/2] fix(extensions): constrain extension names to bare PATH lookups CI's gosec caught a real one. exec.LookPath treats any word containing a separator as a *path* rather than a PATH search, so `cu ../../tmp/evil` would have resolved and run an executable that was never on PATH at all. Extension words are now constrained to ^[a-zA-Z0-9][a-zA-Z0-9_-]*$, so an extension is found on PATH, by name, or not at all; anything else falls through to cobra's unknown-command error. Verified live that ./evil and ../outside/evil are both refused while normal dispatch still works. The suppression comment was also wrong: it named G204 while the rule that fires is G702 (taint analysis), so it never applied to begin with. It now names both and explains why the call is safe rather than asserting it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014aqbmccWm1tqttmBUCR5rv ClickUp: 86dxbeqyt --- internal/cmd/extension.go | 18 ++++++++++++++++-- internal/cmd/extension_test.go | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/internal/cmd/extension.go b/internal/cmd/extension.go index 7375b50..d5076d3 100644 --- a/internal/cmd/extension.go +++ b/internal/cmd/extension.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "github.com/spf13/pflag" @@ -16,6 +17,13 @@ import ( // `cu worklog` runs `cu-worklog` from PATH, the same shape kubectl and gh use. const extensionPrefix = "cu-" +// extensionName constrains a word to a bare command name. Without this, +// exec.LookPath treats anything containing a separator as a *path* rather than +// a PATH search, so `cu ../../tmp/evil` would resolve and run an executable +// that was never on PATH at all. Extensions are found on PATH, by name, or not +// at all. +var extensionName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + // tryExtension runs an external subcommand when cobra cannot resolve the word, // and never returns if it does. It is deliberately the *last* resort: builtins // always win, so an extension can never shadow a cu command. @@ -33,7 +41,7 @@ func tryExtension(args []string) { } word := firstCommandWord(args, rootCmd.PersistentFlags()) - if word == "" || strings.HasPrefix(word, extensionPrefix) { + if word == "" || strings.HasPrefix(word, extensionPrefix) || !extensionName.MatchString(word) { return } @@ -54,7 +62,13 @@ func runExtension(path, word string, args []string) { // flags that look like cu's own — they belong to the extension. rest := argsAfter(args, word) - cmd := exec.Command(path, rest...) // #nosec G204 - path comes from exec.LookPath, args are the user's own + // #nosec G702 G204 -- the resolved path is not attacker-controlled: the word + // is constrained to ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ so it cannot contain a path + // separator, meaning LookPath can only return an executable already on the + // user's own PATH. The arguments are the user's own command line, forwarded + // verbatim, and cross no privilege boundary — this is the same trust model + // as the shell that invoked cu. + cmd := exec.Command(path, rest...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/internal/cmd/extension_test.go b/internal/cmd/extension_test.go index d7b8adf..24a55f0 100644 --- a/internal/cmd/extension_test.go +++ b/internal/cmd/extension_test.go @@ -85,3 +85,20 @@ func TestTryExtensionLeavesBuiltinsAlone(t *testing.T) { tryExtension([]string{"task", "list"}) tryExtension([]string{}) } + +func TestExtensionNameRejectsPaths(t *testing.T) { + // exec.LookPath treats a word containing a separator as a path rather than + // a PATH search, so without this constraint `cu ../../tmp/evil` would run + // an executable that was never on PATH. Extensions are found on PATH, by + // name, or not at all. + for _, bad := range []string{ + "../evil", "../../tmp/evil", "/abs/evil", "dir/evil", + ".hidden", "-leading-dash", "", "has space", "semi;colon", "dot.dot", + } { + assert.False(t, extensionName.MatchString(bad), "must reject %q", bad) + } + + for _, good := range []string{"worklog", "trailer", "my-ext", "my_ext", "ext2"} { + assert.True(t, extensionName.MatchString(good), "must accept %q", good) + } +}