From 7a442a0b1421356876df4e3ce95598ccdda666a9 Mon Sep 17 00:00:00 2001 From: Maciej Lech Date: Sun, 2 Nov 2025 18:29:26 +0100 Subject: [PATCH] feat: add --trust CLI and remote.trust config for remote tasks Signed-off-by: Maciej Lech --- executor.go | 15 ++ internal/flags/flags.go | 3 + setup.go | 1 + task_test.go | 23 +++ taskfile/reader.go | 43 ++++- taskrc/ast/taskrc.go | 26 +++ taskrc/taskrc_test.go | 172 ++++++++++++++++++ .../src/docs/experiments/remote-taskfiles.md | 43 ++++- website/src/public/schema-taskrc.json | 7 + 9 files changed, 330 insertions(+), 3 deletions(-) diff --git a/executor.go b/executor.go index 6ecf910a5b..f396414566 100644 --- a/executor.go +++ b/executor.go @@ -34,6 +34,7 @@ type ( Insecure bool Download bool Offline bool + Trust []string Timeout time.Duration CacheExpiryDuration time.Duration Watch bool @@ -225,6 +226,20 @@ func (o *offlineOption) ApplyToExecutor(e *Executor) { e.Offline = o.offline } +// WithTrust configures the [Executor] with a list of trusted hosts for remote +// Taskfiles. Hosts in this list will not prompt for user confirmation. +func WithTrust(trust []string) ExecutorOption { + return &trustOption{trust} +} + +type trustOption struct { + trust []string +} + +func (o *trustOption) ApplyToExecutor(e *Executor) { + e.Trust = o.trust +} + // WithTimeout sets the [Executor]'s timeout for fetching remote taskfiles. By // default, the timeout is set to 10 seconds. func WithTimeout(timeout time.Duration) ExecutorOption { diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 5787e17a6d..42cd42cbdc 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -73,6 +73,7 @@ var ( Experiments bool Download bool Offline bool + Trust []string ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration @@ -152,6 +153,7 @@ func init() { if experiments.RemoteTaskfiles.Enabled() { pflag.BoolVar(&Download, "download", false, "Downloads a cached version of a remote Taskfile.") pflag.BoolVar(&Offline, "offline", getConfig(config, func() *bool { return config.Remote.Offline }, false), "Forces Task to only use local or cached Taskfiles.") + pflag.StringSliceVar(&Trust, "trust", config.Remote.Trust, "List of trusted hosts for remote Taskfiles (can be specified multiple times).") pflag.DurationVar(&Timeout, "timeout", getConfig(config, func() *time.Duration { return config.Remote.Timeout }, time.Second*10), "Timeout for downloading remote Taskfiles.") pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.") pflag.DurationVar(&CacheExpiryDuration, "expiry", getConfig(config, func() *time.Duration { return config.Remote.CacheExpiry }, 0), "Expiry duration for cached remote Taskfiles.") @@ -238,6 +240,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithInsecure(Insecure), task.WithDownload(Download), task.WithOffline(Offline), + task.WithTrust(Trust), task.WithTimeout(Timeout), task.WithCacheExpiryDuration(CacheExpiryDuration), task.WithWatch(Watch), diff --git a/setup.go b/setup.go index 6234ceefa0..feec03ab49 100644 --- a/setup.go +++ b/setup.go @@ -84,6 +84,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { taskfile.WithInsecure(e.Insecure), taskfile.WithDownload(e.Download), taskfile.WithOffline(e.Offline), + taskfile.WithTrust(e.Trust), taskfile.WithTempDir(e.TempDir.Remote), taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration), taskfile.WithDebugFunc(debugFunc), diff --git a/task_test.go b/task_test.go index 03ee2ee1b2..2aff982d10 100644 --- a/task_test.go +++ b/task_test.go @@ -9,6 +9,7 @@ import ( rand "math/rand/v2" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "regexp" @@ -784,6 +785,11 @@ func TestIncludesRemote(t *testing.T) { var buff SyncBuffer + // Extract host from server URL for trust testing + parsedURL, err := url.Parse(srv.URL) + require.NoError(t, err) + trustedHost := parsedURL.Host + executors := []struct { name string executor *task.Executor @@ -823,6 +829,23 @@ func TestIncludesRemote(t *testing.T) { task.WithOffline(true), ), }, + { + name: "with trust, no prompts", + executor: task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithTimeout(time.Minute), + task.WithInsecure(true), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithVerbose(true), + + // With trust + task.WithTrust([]string{trustedHost}), + task.WithDownload(true), + ), + }, } for _, e := range executors { diff --git a/taskfile/reader.go b/taskfile/reader.go index 3f36ad62b2..2b6b26f34e 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -3,6 +3,7 @@ package taskfile import ( "context" "fmt" + "net/url" "os" "sync" "time" @@ -43,6 +44,7 @@ type ( insecure bool download bool offline bool + trust []string tempDir string cacheExpiryDuration time.Duration debugFunc DebugFunc @@ -59,6 +61,7 @@ func NewReader(opts ...ReaderOption) *Reader { insecure: false, download: false, offline: false, + trust: nil, tempDir: os.TempDir(), cacheExpiryDuration: 0, debugFunc: nil, @@ -119,6 +122,20 @@ func (o *offlineOption) ApplyToReader(r *Reader) { r.offline = o.offline } +// WithTrust configures the [Reader] with a list of trusted hosts for remote +// Taskfiles. Hosts in this list will not prompt for user confirmation. +func WithTrust(trust []string) ReaderOption { + return &trustOption{trust: trust} +} + +type trustOption struct { + trust []string +} + +func (o *trustOption) ApplyToReader(r *Reader) { + r.trust = o.trust +} + // WithTempDir sets the temporary directory that will be used by the [Reader]. // By default, the reader uses [os.TempDir]. func WithTempDir(tempDir string) ReaderOption { @@ -206,6 +223,28 @@ func (r *Reader) promptf(format string, a ...any) error { return nil } +// isTrusted checks if a URI's host matches any of the trusted hosts patterns. +func (r *Reader) isTrusted(uri string) bool { + if len(r.trust) == 0 { + return false + } + + // Parse the URI to extract the host + parsedURL, err := url.Parse(uri) + if err != nil { + return false + } + host := parsedURL.Host + + // Check against each trusted pattern (exact match including port if provided) + for _, pattern := range r.trust { + if host == pattern { + return true + } + } + return false +} + func (r *Reader) include(ctx context.Context, node Node) error { // Create a new vertex for the Taskfile vertex := &ast.TaskfileVertex{ @@ -459,9 +498,9 @@ func (r *Reader) readRemoteNodeContent(ctx context.Context, node RemoteNode) ([] // If there is no manual checksum pin, run the automatic checks if node.Checksum() == "" { - // Prompt the user if required + // Prompt the user if required (unless host is trusted) prompt := cache.ChecksumPrompt(checksum) - if prompt != "" { + if prompt != "" && !r.isTrusted(node.Location()) { if err := func() error { r.promptMutex.Lock() defer r.promptMutex.Unlock() diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 9410ed1738..ee0706bd51 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -21,6 +21,7 @@ type Remote struct { Offline *bool `yaml:"offline"` Timeout *time.Duration `yaml:"timeout"` CacheExpiry *time.Duration `yaml:"cache-expiry"` + Trust []string `yaml:"trust"` } // Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC. @@ -43,6 +44,31 @@ func (t *TaskRC) Merge(other *TaskRC) { t.Remote.Timeout = cmp.Or(other.Remote.Timeout, t.Remote.Timeout) t.Remote.CacheExpiry = cmp.Or(other.Remote.CacheExpiry, t.Remote.CacheExpiry) + // Merge Trust lists - combine both lists with other's entries taking precedence + // Remove duplicates by using a map + if len(other.Remote.Trust) > 0 { + seen := make(map[string]bool) + merged := []string{} + + // Add other's hosts first + for _, host := range other.Remote.Trust { + if !seen[host] { + seen[host] = true + merged = append(merged, host) + } + } + + // Then add base's hosts that aren't duplicates + for _, host := range t.Remote.Trust { + if !seen[host] { + seen[host] = true + merged = append(merged, host) + } + } + + t.Remote.Trust = merged + } + t.Verbose = cmp.Or(other.Verbose, t.Verbose) t.Concurrency = cmp.Or(other.Concurrency, t.Concurrency) } diff --git a/taskrc/taskrc_test.go b/taskrc/taskrc_test.go index 1db8221a6f..085ed49a7f 100644 --- a/taskrc/taskrc_test.go +++ b/taskrc/taskrc_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -135,3 +136,174 @@ func TestGetConfig_All(t *testing.T) { //nolint:paralleltest // cannot run in pa }, }, cfg) } + +func TestGetConfig_RemoteTrust(t *testing.T) { //nolint:paralleltest // cannot run in parallel + _, _, localDir := setupDirs(t) + + // Test with single host + configYAML := ` +remote: + trust: + - github.com +` + writeFile(t, localDir, ".taskrc.yml", configYAML) + + cfg, err := GetConfig(localDir) + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.Equal(t, []string{"github.com"}, cfg.Remote.Trust) + + // Test with multiple hosts + configYAML = ` +remote: + trust: + - github.com + - gitlab.com + - example.com:8080 +` + writeFile(t, localDir, ".taskrc.yml", configYAML) + + cfg, err = GetConfig(localDir) + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.Equal(t, []string{"github.com", "gitlab.com", "example.com:8080"}, cfg.Remote.Trust) +} + +func TestGetConfig_RemoteTrustMerge(t *testing.T) { //nolint:paralleltest // cannot run in parallel + t.Run("file-based merge precedence", func(t *testing.T) { //nolint:paralleltest // parent test cannot run in parallel + xdgConfigDir, homeDir, localDir := setupDirs(t) + + // XDG config has github.com and gitlab.com + xdgConfig := ` +remote: + trust: + - github.com + - gitlab.com + timeout: "30s" +` + writeFile(t, xdgConfigDir, "taskrc.yml", xdgConfig) + + // Home config has example.com (should be combined with XDG) + homeConfig := ` +remote: + trust: + - example.com +` + writeFile(t, homeDir, ".taskrc.yml", homeConfig) + + cfg, err := GetConfig(localDir) + assert.NoError(t, err) + assert.NotNil(t, cfg) + // Home config entries come first, then XDG + assert.Equal(t, []string{"example.com", "github.com", "gitlab.com"}, cfg.Remote.Trust) + + // Test with local config too + localConfig := ` +remote: + trust: + - local.dev +` + writeFile(t, localDir, ".taskrc.yml", localConfig) + + cfg, err = GetConfig(localDir) + assert.NoError(t, err) + assert.NotNil(t, cfg) + // Local config entries come first + assert.Equal(t, []string{"local.dev", "example.com", "github.com", "gitlab.com"}, cfg.Remote.Trust) + }) + + t.Run("merge edge cases", func(t *testing.T) { //nolint:paralleltest // parent test cannot run in parallel + tests := []struct { + name string + base *ast.TaskRC + other *ast.TaskRC + expected []string + }{ + { + name: "merge hosts into empty", + base: &ast.TaskRC{}, + other: &ast.TaskRC{ + Remote: ast.Remote{ + Trust: []string{"github.com"}, + }, + }, + expected: []string{"github.com"}, + }, + { + name: "merge combines lists", + base: &ast.TaskRC{ + Remote: ast.Remote{ + Trust: []string{"base.com"}, + }, + }, + other: &ast.TaskRC{ + Remote: ast.Remote{ + Trust: []string{"other.com"}, + }, + }, + expected: []string{"other.com", "base.com"}, + }, + { + name: "merge empty list does not override", + base: &ast.TaskRC{ + Remote: ast.Remote{ + Trust: []string{"base.com"}, + }, + }, + other: &ast.TaskRC{ + Remote: ast.Remote{ + Trust: []string{}, + }, + }, + expected: []string{"base.com"}, + }, + { + name: "merge nil does not override", + base: &ast.TaskRC{ + Remote: ast.Remote{ + Trust: []string{"base.com"}, + }, + }, + other: &ast.TaskRC{ + Remote: ast.Remote{ + Trust: nil, + }, + }, + expected: []string{"base.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest // parent test cannot run in parallel + tt.base.Merge(tt.other) + assert.Equal(t, tt.expected, tt.base.Remote.Trust) + }) + } + }) + + t.Run("all remote fields merge", func(t *testing.T) { //nolint:paralleltest // parent test cannot run in parallel + insecureTrue := true + offlineTrue := true + timeout := 30 * time.Second + cacheExpiry := 1 * time.Hour + + base := &ast.TaskRC{} + other := &ast.TaskRC{ + Remote: ast.Remote{ + Insecure: &insecureTrue, + Offline: &offlineTrue, + Timeout: &timeout, + CacheExpiry: &cacheExpiry, + Trust: []string{"github.com", "gitlab.com"}, + }, + } + + base.Merge(other) + + assert.Equal(t, &insecureTrue, base.Remote.Insecure) + assert.Equal(t, &offlineTrue, base.Remote.Offline) + assert.Equal(t, &timeout, base.Remote.Timeout) + assert.Equal(t, &cacheExpiry, base.Remote.CacheExpiry) + assert.Equal(t, []string{"github.com", "gitlab.com"}, base.Remote.Trust) + }) +} diff --git a/website/src/docs/experiments/remote-taskfiles.md b/website/src/docs/experiments/remote-taskfiles.md index 88a8cc799f..8053e3be00 100644 --- a/website/src/docs/experiments/remote-taskfiles.md +++ b/website/src/docs/experiments/remote-taskfiles.md @@ -214,7 +214,10 @@ remote Taskfiles: Sometimes you need to run Task in an environment that does not have an interactive terminal, so you are not able to accept a prompt. In these cases you are able to tell task to accept these prompts automatically by using the `--yes` -flag. Before enabling this flag, you should: +flag or the `--trust` flag. The `--trust` flag allows you to specify trusted +hosts for remote Taskfiles, while `--yes` applies to all prompts in Task. You +can also configure trusted hosts in your [taskrc configuration](#trust) using +`remote.trust`. Before enabling automatic trust, you should: 1. Be sure that you trust the source and contents of the remote Taskfile. 2. Consider using a pinned version of the remote Taskfile (e.g. A link @@ -305,6 +308,9 @@ remote: offline: false timeout: "30s" cache-expiry: "24h" + trust: + - github.com + - gitlab.com ``` #### `insecure` @@ -353,3 +359,38 @@ remote: remote: cache-expiry: "6h" ``` + +#### `trust` + +- **Type**: `array of strings` +- **Default**: `[]` (empty list) +- **Description**: List of trusted hosts for remote Taskfiles. Hosts in this + list will not prompt for confirmation when downloading Taskfiles +- **CLI equivalent**: `--trust` + +```yaml +remote: + trust: + - github.com + - gitlab.com + - raw.githubusercontent.com + - example.com:8080 +``` + +Hosts in the trust list will automatically be trusted without prompting for +confirmation when they are first downloaded or when their checksums change. The +host matching includes the port if specified in the URL. Use with caution and +only add hosts you fully trust. + +You can also specify trusted hosts via the command line: + +```shell +# Trust specific host for this execution +task --trust github.com -t https://github.com/user/repo.git//Taskfile.yml + +# Trust multiple hosts +task --trust github.com --trust gitlab.com -t https://github.com/user/repo.git//Taskfile.yml + +# Trust a host with a specific port +task --trust example.com:8080 -t https://example.com:8080/Taskfile.yml +``` diff --git a/website/src/public/schema-taskrc.json b/website/src/public/schema-taskrc.json index ac101acfb8..9357f9707b 100644 --- a/website/src/public/schema-taskrc.json +++ b/website/src/public/schema-taskrc.json @@ -42,6 +42,13 @@ "type": "string", "description": "Expiry duration for cached remote Taskfiles (e.g., '1h', '24h')", "pattern": "^[0-9]+(ns|us|µs|ms|s|m|h)$" + }, + "trust": { + "type": "array", + "description": "List of trusted hosts for remote Taskfiles (e.g., 'github.com', 'gitlab.com', 'example.com:8080').", + "items": { + "type": "string" + } } }, "additionalProperties": false