From 2f6483045ac793768ff751f7c949758a584f892d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jendrysik?= Date: Mon, 27 Apr 2026 12:06:52 +0200 Subject: [PATCH 01/57] Pave road to no-checkout-override --- agent/agent_configuration.go | 1 + .../job_runner_integration_test.go | 61 +++++++++++++++++++ agent/job_runner.go | 2 + clicommand/agent_start.go | 3 + clicommand/bootstrap.go | 3 + clicommand/global.go | 6 ++ internal/job/api.go | 3 + internal/job/config.go | 3 + jobapi/server.go | 13 +++- 9 files changed, 92 insertions(+), 3 deletions(-) diff --git a/agent/agent_configuration.go b/agent/agent_configuration.go index 3f1220e6a0..143e139676 100644 --- a/agent/agent_configuration.go +++ b/agent/agent_configuration.go @@ -28,6 +28,7 @@ type AgentConfiguration struct { GitSubmoduleCloneConfig []string SkipCheckout bool GitSkipFetchExistingCommits bool + NoCheckoutOverride bool CheckoutAttempts int AllowedRepositories []*regexp.Regexp AllowedPlugins []*regexp.Regexp diff --git a/agent/integration/job_runner_integration_test.go b/agent/integration/job_runner_integration_test.go index d4ba44c531..7b0e655813 100644 --- a/agent/integration/job_runner_integration_test.go +++ b/agent/integration/job_runner_integration_test.go @@ -17,6 +17,7 @@ import ( "github.com/buildkite/agent/v3/agent" "github.com/buildkite/agent/v3/api" + "github.com/buildkite/agent/v3/internal/experiments" "github.com/buildkite/bintest/v3" "gotest.tools/v3/assert" ) @@ -276,6 +277,66 @@ func TestJobRunner_WhenJobHasToken_ItOverridesAccessToken(t *testing.T) { } } +func TestJobRunnerPassesNoCheckoutOverrideToBootstrapAndEnvFile(t *testing.T) { + t.Parallel() + + ctx, _ := experiments.Enable(context.Background(), experiments.PropagateAgentConfigVars) + + j := &api.Job{ + ID: "my-job-id", + ChunksMaxSizeBytes: 1024, + Env: map[string]string{ + "BUILDKITE_COMMAND": "echo hello world", + }, + Token: "bkaj_job-token", + } + + mb := mockBootstrap(t) + t.Cleanup(func() { + if err := mb.CheckAndClose(t); err != nil { + t.Errorf("mb.CheckAndClose(t) error = %v", err) + } + }) + + mb.Expect().Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + if got, want := c.GetEnv("BUILDKITE_NO_CHECKOUT_OVERRIDE"), "true"; got != want { + t.Errorf("c.GetEnv(BUILDKITE_NO_CHECKOUT_OVERRIDE) = %q, want %q", got, want) + c.Exit(1) + return + } + + envFile := c.GetEnv("BUILDKITE_ENV_FILE") + contents, err := os.ReadFile(envFile) + if err != nil { + t.Errorf("os.ReadFile(%q) error = %v", envFile, err) + c.Exit(1) + return + } + + if !strings.Contains(string(contents), "BUILDKITE_NO_CHECKOUT_OVERRIDE") { + t.Errorf("env file %q did not contain BUILDKITE_NO_CHECKOUT_OVERRIDE", envFile) + c.Exit(1) + return + } + + c.Exit(0) + }) + + e := createTestAgentEndpoint() + server := e.server() + defer server.Close() + + err := runJob(t, ctx, testRunJobConfig{ + job: j, + server: server, + agentCfg: agent.AgentConfiguration{NoCheckoutOverride: true}, + mockBootstrap: mb, + }) + if err != nil { + t.Fatalf("runJob() error = %v", err) + } +} + // TODO 2023-07-17: What is this testing? How is it testing it? // Maybe that the job runner pulls the access token from the API client? but that's all handled in the `runJob` helper... func TestJobRunnerPassesAccessTokenToBootstrap(t *testing.T) { diff --git a/agent/job_runner.go b/agent/job_runner.go index 92a481f7c5..48023894e3 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -454,6 +454,7 @@ BUILDKITE_GIT_MIRRORS_PATH BUILDKITE_GIT_MIRRORS_SKIP_UPDATE BUILDKITE_GIT_SUBMODULES BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG +BUILDKITE_NO_CHECKOUT_OVERRIDE BUILDKITE_CANCEL_GRACE_PERIOD BUILDKITE_COMMAND_EVAL BUILDKITE_LOCAL_HOOKS_ENABLED @@ -577,6 +578,7 @@ BUILDKITE_AGENT_JWKS_KEY_ID` if r.conf.AgentConfiguration.GitSkipFetchExistingCommits { setEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", "true") } + setEnv("BUILDKITE_NO_CHECKOUT_OVERRIDE", fmt.Sprint(r.conf.AgentConfiguration.NoCheckoutOverride)) setEnv("BUILDKITE_CHECKOUT_ATTEMPTS", strconv.Itoa(r.conf.AgentConfiguration.CheckoutAttempts)) setEnv("BUILDKITE_COMMAND_EVAL", fmt.Sprint(r.conf.AgentConfiguration.CommandEval)) setEnv("BUILDKITE_PLUGINS_ENABLED", fmt.Sprint(r.conf.AgentConfiguration.PluginsEnabled)) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index b72e0722ca..e57aea98c0 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -169,6 +169,7 @@ type AgentStartConfig struct { GitSubmoduleCloneConfig []string `cli:"git-submodule-clone-config"` SkipCheckout bool `cli:"skip-checkout"` GitSkipFetchExistingCommits bool `cli:"git-skip-fetch-existing-commits"` + NoCheckoutOverride bool `cli:"no-checkout-override"` CheckoutAttempts int `cli:"checkout-attempts"` NoSSHKeyscan bool `cli:"no-ssh-keyscan"` @@ -524,6 +525,7 @@ var AgentStartCommand = cli.Command{ // Various git related flags shared with bootstrap SkipCheckoutFlag, + NoCheckoutOverrideFlag, GitCheckoutFlagsFlag, GitCloneFlagsFlag, GitCleanFlagsFlag, @@ -1065,6 +1067,7 @@ var AgentStartCommand = cli.Command{ GitSubmoduleCloneConfig: cfg.GitSubmoduleCloneConfig, SkipCheckout: cfg.SkipCheckout, GitSkipFetchExistingCommits: cfg.GitSkipFetchExistingCommits, + NoCheckoutOverride: cfg.NoCheckoutOverride, CheckoutAttempts: cfg.CheckoutAttempts, SSHKeyscan: !cfg.NoSSHKeyscan, CommandEval: !cfg.NoCommandEval, diff --git a/clicommand/bootstrap.go b/clicommand/bootstrap.go index ff5b4c6624..ddc3d7daf8 100644 --- a/clicommand/bootstrap.go +++ b/clicommand/bootstrap.go @@ -81,6 +81,7 @@ type BootstrapConfig struct { GitMirrorsLockTimeout int `cli:"git-mirrors-lock-timeout"` GitMirrorsSkipUpdate bool `cli:"git-mirrors-skip-update"` GitSubmoduleCloneConfig []string `cli:"git-submodule-clone-config" normalize:"list"` + NoCheckoutOverride bool `cli:"no-checkout-override"` BinPath string `cli:"bin-path" normalize:"filepath"` BuildPath string `cli:"build-path" normalize:"filepath"` HooksPath string `cli:"hooks-path" normalize:"filepath"` @@ -236,6 +237,7 @@ var BootstrapCommand = cli.Command{ // Various git related flags shared with agent start SkipCheckoutFlag, + NoCheckoutOverrideFlag, GitCheckoutFlagsFlag, GitCloneFlagsFlag, GitCloneMirrorFlagsFlag, @@ -431,6 +433,7 @@ var BootstrapCommand = cli.Command{ CleanCheckout: cfg.CleanCheckout, SkipCheckout: cfg.SkipCheckout, GitSkipFetchExistingCommits: cfg.GitSkipFetchExistingCommits, + NoCheckoutOverride: cfg.NoCheckoutOverride, Command: cfg.Command, CommandEval: cfg.CommandEval, Commit: cfg.Commit, diff --git a/clicommand/global.go b/clicommand/global.go index 04120169d6..ef951e42a8 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -193,6 +193,12 @@ var ( EnvVar: "BUILDKITE_SKIP_CHECKOUT", } + NoCheckoutOverrideFlag = cli.BoolFlag{ + Name: "no-checkout-override", + Usage: "Prevent hooks, plugins, Job API, and secrets from overriding scoped checkout settings (default: false)", + EnvVar: "BUILDKITE_NO_CHECKOUT_OVERRIDE", + } + GitCheckoutFlagsFlag = cli.StringFlag{ Name: "git-checkout-flags", Value: "-f", diff --git a/internal/job/api.go b/internal/job/api.go index e9eefa5540..2788f68b85 100644 --- a/internal/job/api.go +++ b/internal/job/api.go @@ -30,6 +30,9 @@ We'll continue to run your job, but you won't be able to use the Job API`) if e.Debug { jobAPIOpts = append(jobAPIOpts, jobapi.WithDebug()) } + if e.NoCheckoutOverride { + jobAPIOpts = append(jobAPIOpts, jobapi.WithNoCheckoutOverride(true)) + } srv, token, err := jobapi.NewServer(e.shell.Logger, socketPath, e.shell.Env, e.redactors, jobAPIOpts...) if err != nil { return cleanup, fmt.Errorf("creating job API server: %w", err) diff --git a/internal/job/config.go b/internal/job/config.go index ab59d0b0e9..4d46cf6602 100644 --- a/internal/job/config.go +++ b/internal/job/config.go @@ -83,6 +83,9 @@ type ExecutorConfig struct { // Skip git fetch if the commit already exists locally GitSkipFetchExistingCommits bool `env:"BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS"` + // This intentionally has no env tag so hooks cannot disable it at runtime. + NoCheckoutOverride bool + // Flags to pass to "git checkout" command GitCheckoutFlags string `env:"BUILDKITE_GIT_CHECKOUT_FLAGS"` diff --git a/jobapi/server.go b/jobapi/server.go index ba24159aeb..90ccd0da7d 100644 --- a/jobapi/server.go +++ b/jobapi/server.go @@ -22,6 +22,12 @@ func WithDebug() ServerOpts { } } +func WithNoCheckoutOverride(enabled bool) ServerOpts { + return func(s *Server) { + s.noCheckoutOverride = enabled + } +} + // Server is a Job API server. It provides an HTTP API with which to interact with the job currently running in the buildkite agent // and allows jobs to introspect and mutate their own state type Server struct { @@ -30,9 +36,10 @@ type Server struct { Logger shell.Logger debug bool - mtx sync.RWMutex - environ *env.Environment - redactors *replacer.Mux + mtx sync.RWMutex + environ *env.Environment + redactors *replacer.Mux + noCheckoutOverride bool token string sockSvr *socket.Server From 8df61274b9ea1f4b501cdbf053f5cd61194ee79d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jendrysik?= Date: Mon, 27 Apr 2026 12:09:28 +0200 Subject: [PATCH 02/57] Define checkout override scope for runtime env mutations --- env/protected.go | 85 +++++++++++++++++++++--------------------- internal/job/config.go | 6 ++- jobapi/env.go | 9 +++-- 3 files changed, 53 insertions(+), 47 deletions(-) diff --git a/env/protected.go b/env/protected.go index d4360dede8..3cdbafc19f 100644 --- a/env/protected.go +++ b/env/protected.go @@ -22,16 +22,6 @@ type protection struct { // disables plugins, but even if it is changed by a hook, the agent doesn't // reconfigure no-command-eval based on any changes.) // -// Git flags are an example of vars that are primarily driven by agent config, -// but we want to allow plugins to set git flags to alter the default checkout -// process (see for example the git-clean plugin). Doing this deliberately -// reconfigures the executor (see ReadFromEnvironment and config struct tags in -// internal/job/config.go). -// But we don't want the job env from the backend to be able to set them, -// because git is riddled with shell injections, and someone with privileges to -// start a build and supply env vars could then bypass protections like -// no-command-eval. -// // The actual enforcement of protected env within the agent level (overriding // job-level env vars based on agent configuration) happens implicitly rather // than relying on this map - see createEnvironment in agent/job_runner.go. @@ -45,41 +35,47 @@ type protection struct { // filter backend-supplied vars, and such vars are still necessary for a job to // function. // -// When updating ExecutorConfig in internal/job/config.go, ensure -// mutableFromWithinJob is enabled here for reconfigurable vars. +// When updating ExecutorConfig in internal/job/config.go, ensure always- +// protected reconfigurable vars set mutableFromWithinJob here, and checkout- +// scoped vars are added to checkoutOverrideScope below. var protectedEnv = map[string]protection{ - "BUILDKITE_AGENT_ACCESS_TOKEN": {}, - "BUILDKITE_AGENT_DEBUG": {}, - "BUILDKITE_AGENT_ENDPOINT": {}, - "BUILDKITE_AGENT_PID": {}, - "BUILDKITE_ARTIFACT_PATHS": {mutableFromWithinJob: true}, - "BUILDKITE_ARTIFACT_UPLOAD_DESTINATION": {mutableFromWithinJob: true}, - "BUILDKITE_BIN_PATH": {}, - "BUILDKITE_BUILD_PATH": {}, - "BUILDKITE_COMMAND_EVAL": {}, - "BUILDKITE_CONFIG_PATH": {}, - "BUILDKITE_CONTAINER_COUNT": {}, - "BUILDKITE_GIT_CHECKOUT_FLAGS": {mutableFromWithinJob: true}, - "BUILDKITE_GIT_CLEAN_FLAGS": {mutableFromWithinJob: true}, - "BUILDKITE_GIT_CLONE_FLAGS": {mutableFromWithinJob: true}, - "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": {mutableFromWithinJob: true}, - "BUILDKITE_GIT_FETCH_FLAGS": {mutableFromWithinJob: true}, + "BUILDKITE_AGENT_ACCESS_TOKEN": {}, + "BUILDKITE_AGENT_DEBUG": {}, + "BUILDKITE_AGENT_ENDPOINT": {}, + "BUILDKITE_AGENT_PID": {}, + "BUILDKITE_ARTIFACT_PATHS": {mutableFromWithinJob: true}, + "BUILDKITE_ARTIFACT_UPLOAD_DESTINATION": {mutableFromWithinJob: true}, + "BUILDKITE_BIN_PATH": {}, + "BUILDKITE_BUILD_PATH": {}, + "BUILDKITE_COMMAND_EVAL": {}, + "BUILDKITE_CONFIG_PATH": {}, + "BUILDKITE_CONTAINER_COUNT": {}, + "BUILDKITE_HOOKS_PATH": {}, + "BUILDKITE_HOOKS_SHELL": {}, + "BUILDKITE_KUBERNETES_EXEC": {}, + "BUILDKITE_LOCAL_HOOKS_ENABLED": {}, + "BUILDKITE_NO_CHECKOUT_OVERRIDE": {}, + "BUILDKITE_PLUGINS_ALWAYS_CLONE_FRESH": {mutableFromWithinJob: true}, + "BUILDKITE_PLUGINS_ENABLED": {}, + "BUILDKITE_PLUGINS_PATH": {}, + "BUILDKITE_REFSPEC": {mutableFromWithinJob: true}, + "BUILDKITE_REPO": {mutableFromWithinJob: true}, + "BUILDKITE_SHELL": {}, +} + +var checkoutOverrideScope = map[string]struct{}{ + "BUILDKITE_GIT_CHECKOUT_FLAGS": {}, + "BUILDKITE_GIT_CLEAN_FLAGS": {}, + "BUILDKITE_GIT_CLONE_FLAGS": {}, + "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": {}, + "BUILDKITE_GIT_FETCH_FLAGS": {}, "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT": {}, "BUILDKITE_GIT_MIRRORS_PATH": {}, - "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": {mutableFromWithinJob: true}, - "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": {mutableFromWithinJob: true}, - "BUILDKITE_GIT_SUBMODULES": {mutableFromWithinJob: true}, - "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG": {mutableFromWithinJob: true}, - "BUILDKITE_HOOKS_PATH": {}, - "BUILDKITE_HOOKS_SHELL": {}, - "BUILDKITE_KUBERNETES_EXEC": {}, - "BUILDKITE_LOCAL_HOOKS_ENABLED": {}, - "BUILDKITE_PLUGINS_ALWAYS_CLONE_FRESH": {mutableFromWithinJob: true}, - "BUILDKITE_PLUGINS_ENABLED": {}, - "BUILDKITE_PLUGINS_PATH": {}, - "BUILDKITE_REFSPEC": {mutableFromWithinJob: true}, - "BUILDKITE_REPO": {mutableFromWithinJob: true}, - "BUILDKITE_SHELL": {}, + "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": {}, + "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": {}, + "BUILDKITE_GIT_SUBMODULES": {}, + "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG": {}, + "BUILDKITE_SKIP_CHECKOUT": {}, "BUILDKITE_SSH_KEYSCAN": {}, } @@ -100,3 +96,8 @@ func IsProtectedFromWithinJob(name string) bool { } return !prot.mutableFromWithinJob } + +func IsCheckoutOverrideScoped(name string) bool { + _, exists := checkoutOverrideScope[normalizeKeyName(name)] + return exists +} diff --git a/internal/job/config.go b/internal/job/config.go index 4d46cf6602..f1073fd91f 100644 --- a/internal/job/config.go +++ b/internal/job/config.go @@ -20,7 +20,7 @@ import ( // To add a new config option that is mapped from an environment variable, add a // struct tag, then don't forget to add a corresponding CLI flag over in the // clicommand/bootstrap.go(BootstrapConfig) struct, otherwise it won't work. -// Also check the protectedEnv map in env/protected.go. +// Also check protectedEnv and checkoutOverrideScope in env/protected.go. type ExecutorConfig struct { // The command to run @@ -228,6 +228,10 @@ func (c *ExecutorConfig) ReadFromEnvironment(environ *env.Environment) map[strin // Find struct fields with env tag if tag := f.Tag.Get("env"); tag != "" && environ.Exists(tag) { + if c.NoCheckoutOverride && env.IsCheckoutOverrideScoped(tag) { + continue + } + newStr, _ := environ.Get(tag) switch v.Kind() { diff --git a/jobapi/env.go b/jobapi/env.go index c60ec34d46..8e699cc5db 100644 --- a/jobapi/env.go +++ b/jobapi/env.go @@ -36,7 +36,7 @@ func (s *Server) patchEnv(w http.ResponseWriter, r *http.Request) { added := make([]string, 0, len(req.Env)) updated := make([]string, 0, len(req.Env)) - protected := checkProtected(slices.Collect(maps.Keys(req.Env))) + protected := s.checkProtected(slices.Collect(maps.Keys(req.Env))) if len(protected) > 0 { err := socket.WriteError( @@ -106,7 +106,7 @@ func (s *Server) deleteEnv(w http.ResponseWriter, r *http.Request) { return } - protected := checkProtected(req.Keys) + protected := s.checkProtected(req.Keys) if len(protected) > 0 { err := socket.WriteError( w, @@ -138,12 +138,13 @@ func (s *Server) deleteEnv(w http.ResponseWriter, r *http.Request) { } } -func checkProtected(candidates []string) []string { +func (s *Server) checkProtected(candidates []string) []string { protected := make([]string, 0, len(candidates)) for _, c := range candidates { // The Job API is only accessible from within the job, so allow writes // to vars that allow write from within job. - if env.IsProtectedFromWithinJob(c) { + if env.IsProtectedFromWithinJob(c) || + (s.noCheckoutOverride && env.IsCheckoutOverrideScoped(c)) { protected = append(protected, c) } } From 9ff9d6bd4c3d047afdf30febbbbd209cdd037452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jendrysik?= Date: Mon, 27 Apr 2026 12:11:34 +0200 Subject: [PATCH 03/57] Checkout override in executor --- internal/job/executor.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/job/executor.go b/internal/job/executor.go index 5e0d38f0b5..432a9ae1a1 100644 --- a/internal/job/executor.go +++ b/internal/job/executor.go @@ -634,7 +634,8 @@ func (e *Executor) applyEnvironmentChanges(changes hook.EnvChanges) { // breaks the rest of the job. var protected []string for k := range changes.Diff.Keys { - if env.IsProtectedFromWithinJob(k) { + if env.IsProtectedFromWithinJob(k) || + (e.NoCheckoutOverride && env.IsCheckoutOverrideScoped(k)) { protected = append(protected, k) changes.Diff.Remove(k) } @@ -975,6 +976,9 @@ func (e *Executor) fetchAndSetSecrets(ctx context.Context) error { if env.IsProtected(pipelineSecret.EnvironmentVariable) { return fmt.Errorf("secret %q cannot set protected environment variable %q", pipelineSecret.Key, pipelineSecret.EnvironmentVariable) } + if e.NoCheckoutOverride && env.IsCheckoutOverrideScoped(pipelineSecret.EnvironmentVariable) { + return fmt.Errorf("secret %q cannot set checkout-locked environment variable %q while BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled", pipelineSecret.Key, pipelineSecret.EnvironmentVariable) + } var alreadySet bool _, alreadySet = e.shell.Env.Get(pipelineSecret.EnvironmentVariable) From cfdf7197c7d064ffd7dd5bfde39874ae7b67a852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jendrysik?= Date: Mon, 27 Apr 2026 12:12:08 +0200 Subject: [PATCH 04/57] Add unit tests for no-checkout-override --- env/protected_test.go | 61 +++++++++++++++--- internal/job/config_test.go | 40 ++++++++++++ jobapi/server_test.go | 125 +++++++++++++++++++++++++++++++++++- 3 files changed, 215 insertions(+), 11 deletions(-) diff --git a/env/protected_test.go b/env/protected_test.go index 01e5819c41..9653e3429a 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -17,22 +17,14 @@ func TestProtectedEnv(t *testing.T) { "BUILDKITE_COMMAND_EVAL", "BUILDKITE_CONFIG_PATH", "BUILDKITE_CONTAINER_COUNT", - "BUILDKITE_GIT_CLEAN_FLAGS", - "BUILDKITE_GIT_CLONE_FLAGS", - "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", - "BUILDKITE_GIT_FETCH_FLAGS", - "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", - "BUILDKITE_GIT_MIRRORS_PATH", - "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", - "BUILDKITE_GIT_SUBMODULES", "BUILDKITE_HOOKS_PATH", "BUILDKITE_KUBERNETES_EXEC", "BUILDKITE_LOCAL_HOOKS_ENABLED", + "BUILDKITE_NO_CHECKOUT_OVERRIDE", "BUILDKITE_PLUGINS_ENABLED", "BUILDKITE_PLUGINS_PATH", "BUILDKITE_SHELL", "BUILDKITE_HOOKS_SHELL", - "BUILDKITE_SSH_KEYSCAN", } // Verify that all expected variables are protected @@ -48,6 +40,10 @@ func TestProtectedEnv(t *testing.T) { "SECRET_KEY", "DATABASE_URL", "API_TOKEN", + "BUILDKITE_GIT_CLONE_FLAGS", + "BUILDKITE_GIT_SUBMODULES", + "BUILDKITE_SKIP_CHECKOUT", + "BUILDKITE_SSH_KEYSCAN", "BUILDKITE_BRANCH", // This is a standard build env var, not protected "BUILDKITE_COMMIT", // This is a standard build env var, not protected "BUILDKITE_MESSAGE", // This is a standard build env var, not protected @@ -69,3 +65,50 @@ func TestIsProtected_Normalised(t *testing.T) { t.Errorf("IsProtected(%q) = %t, want %t", name, got, want) } } + +func TestCheckoutOverrideScope(t *testing.T) { + t.Parallel() + + scoped := []string{ + "BUILDKITE_GIT_CHECKOUT_FLAGS", + "BUILDKITE_GIT_CLONE_FLAGS", + "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", + "BUILDKITE_GIT_CLEAN_FLAGS", + "BUILDKITE_GIT_FETCH_FLAGS", + "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", + "BUILDKITE_GIT_MIRRORS_PATH", + "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", + "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", + "BUILDKITE_GIT_SUBMODULES", + "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", + "BUILDKITE_SKIP_CHECKOUT", + "BUILDKITE_SSH_KEYSCAN", + } + + for _, envVar := range scoped { + if got := IsCheckoutOverrideScoped(envVar); !got { + t.Errorf("IsCheckoutOverrideScoped(%q) = false, want true", envVar) + } + } + + for _, envVar := range []string{ + "BUILDKITE_GIT_CLONE_FLAGS", + "BUILDKITE_GIT_SUBMODULES", + "BUILDKITE_SKIP_CHECKOUT", + "BUILDKITE_SSH_KEYSCAN", + } { + if got := IsProtected(envVar); got { + t.Errorf("IsProtected(%q) = true, want false", envVar) + } + } + + for _, envVar := range []string{ + "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", + "BUILDKITE_COMMAND_EVAL", + "MY_CUSTOM_VAR", + } { + if got := IsCheckoutOverrideScoped(envVar); got { + t.Errorf("IsCheckoutOverrideScoped(%q) = true, want false", envVar) + } + } +} diff --git a/internal/job/config_test.go b/internal/job/config_test.go index 00c3448f0e..4a148ce249 100644 --- a/internal/job/config_test.go +++ b/internal/job/config_test.go @@ -93,6 +93,46 @@ func TestReadFromEnvironmentIgnoresMalformedBooleans(t *testing.T) { } } +func TestReadFromEnvironmentDoesNotRefreshNoCheckoutOverride(t *testing.T) { + t.Parallel() + + config := &ExecutorConfig{NoCheckoutOverride: true} + environ := env.FromSlice([]string{"BUILDKITE_NO_CHECKOUT_OVERRIDE=false"}) + + changes := config.ReadFromEnvironment(environ) + if len(changes) != 0 { + t.Errorf("changes = %v, want none", changes) + } + if got, want := config.NoCheckoutOverride, true; got != want { + t.Errorf("config.NoCheckoutOverride = %t, want %t", got, want) + } +} + +func TestReadFromEnvironmentSkipsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *testing.T) { + t.Parallel() + + config := &ExecutorConfig{ + NoCheckoutOverride: true, + SkipCheckout: false, + GitCloneFlags: "-v", + } + environ := env.FromSlice([]string{ + "BUILDKITE_SKIP_CHECKOUT=true", + "BUILDKITE_GIT_CLONE_FLAGS=--mirror", + }) + + changes := config.ReadFromEnvironment(environ) + if len(changes) != 0 { + t.Errorf("changes = %v, want none", changes) + } + if got, want := config.SkipCheckout, false; got != want { + t.Errorf("config.SkipCheckout = %t, want %t", got, want) + } + if got, want := config.GitCloneFlags, "-v"; got != want { + t.Errorf("config.GitCloneFlags = %q, want %q", got, want) + } +} + func TestGitSubmodulesBidirectionalControl(t *testing.T) { t.Parallel() diff --git a/jobapi/server_test.go b/jobapi/server_test.go index 232c862a70..86b2d93e14 100644 --- a/jobapi/server_test.go +++ b/jobapi/server_test.go @@ -36,12 +36,18 @@ func testEnviron() *env.Environment { return e } -func testServer(t *testing.T, e *env.Environment, mux *replacer.Mux) (*jobapi.Server, string, error) { +func testEnvironWith(key, value string) *env.Environment { + e := testEnviron() + e.Set(key, value) + return e +} + +func testServer(t *testing.T, e *env.Environment, mux *replacer.Mux, opts ...jobapi.ServerOpts) (*jobapi.Server, string, error) { sockName, err := jobapi.NewSocketPath(os.TempDir()) if err != nil { return nil, "", fmt.Errorf("creating socket path: %w", err) } - return jobapi.NewServer(shell.TestingLogger{T: t}, sockName, e, mux) + return jobapi.NewServer(shell.TestingLogger{T: t}, sockName, e, mux, opts...) } func testSocketClient(socketPath string) *http.Client { @@ -345,6 +351,121 @@ func TestPatchEnv(t *testing.T) { } } +func TestPatchEnvAllowsCheckoutScopedVarsWhenNoCheckoutOverrideDisabled(t *testing.T) { + t.Parallel() + + environ := testEnvironWith("BUILDKITE_GIT_CLONE_FLAGS", "-v") + srv, token, err := testServer(t, environ, replacer.NewMux()) + if err != nil { + t.Fatalf("creating server: %v", err) + } + + if err := srv.Start(); err != nil { + t.Fatalf("starting server: %v", err) + } + defer func() { + if err := srv.Stop(); err != nil { + t.Fatalf("stopping server: %v", err) + } + }() + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(&jobapi.EnvUpdateRequestPayload{ + Env: map[string]*string{"BUILDKITE_GIT_CLONE_FLAGS": pt("--mirror")}, + }); err != nil { + t.Fatalf("encoding request body: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, "http://job/api/current-job/v0/env", buf) + if err != nil { + t.Fatalf("creating request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvUpdateRequestPayload, jobapi.EnvUpdateResponse]{ + expectedStatus: http.StatusOK, + expectedEnv: testEnvironWith("BUILDKITE_GIT_CLONE_FLAGS", "--mirror").Dump(), + }) +} + +func TestPatchEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *testing.T) { + t.Parallel() + + environ := testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false") + srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride(true)) + if err != nil { + t.Fatalf("creating server: %v", err) + } + + if err := srv.Start(); err != nil { + t.Fatalf("starting server: %v", err) + } + defer func() { + if err := srv.Stop(); err != nil { + t.Fatalf("stopping server: %v", err) + } + }() + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(&jobapi.EnvUpdateRequestPayload{ + Env: map[string]*string{"BUILDKITE_SKIP_CHECKOUT": pt("true")}, + }); err != nil { + t.Fatalf("encoding request body: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, "http://job/api/current-job/v0/env", buf) + if err != nil { + t.Fatalf("creating request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvUpdateRequestPayload, jobapi.EnvUpdateResponse]{ + expectedStatus: http.StatusUnprocessableEntity, + expectedError: &jobapi.ErrorResponse{ + Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_SKIP_CHECKOUT]", + }, + expectedEnv: testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false").Dump(), + }) +} + +func TestDeleteEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *testing.T) { + t.Parallel() + + environ := testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false") + srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride(true)) + if err != nil { + t.Fatalf("creating server: %v", err) + } + + if err := srv.Start(); err != nil { + t.Fatalf("starting server: %v", err) + } + defer func() { + if err := srv.Stop(); err != nil { + t.Fatalf("stopping server: %v", err) + } + }() + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(&jobapi.EnvDeleteRequest{Keys: []string{"BUILDKITE_SKIP_CHECKOUT"}}); err != nil { + t.Fatalf("encoding request body: %v", err) + } + + req, err := http.NewRequest(http.MethodDelete, "http://job/api/current-job/v0/env", buf) + if err != nil { + t.Fatalf("creating request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvDeleteRequest, jobapi.EnvDeleteResponse]{ + expectedStatus: http.StatusUnprocessableEntity, + expectedError: &jobapi.ErrorResponse{ + Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_SKIP_CHECKOUT]", + }, + expectedEnv: testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false").Dump(), + }) +} + func TestGetEnv(t *testing.T) { t.Parallel() From 3354638189f8a94e2122748451d15ef6fefdb38d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jendrysik?= Date: Mon, 27 Apr 2026 12:20:21 +0200 Subject: [PATCH 05/57] Fix env set panic on Job API errors --- clicommand/env_set.go | 4 ++-- clicommand/env_unset.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/clicommand/env_set.go b/clicommand/env_set.go index ed1b99339e..cfdf6d9f5d 100644 --- a/clicommand/env_set.go +++ b/clicommand/env_set.go @@ -69,7 +69,7 @@ var EnvSetCommand = cli.Command{ func envSetAction(c *cli.Context) error { ctx := context.Background() - ctx, cfg, l, _, done := setupLoggerAndConfig[EnvSetConfig](ctx, c) + ctx, cfg, _, _, done := setupLoggerAndConfig[EnvSetConfig](ctx, c) defer done() client, err := jobapi.NewDefaultClient(ctx) @@ -129,7 +129,7 @@ func envSetAction(c *cli.Context) error { resp, err := client.EnvUpdate(ctx, req) if err != nil { - l.Error("Couldn't update the job executor environment: %v", err) + return fmt.Errorf("couldn't update the job executor environment: %w", err) } switch cfg.OutputFormat { diff --git a/clicommand/env_unset.go b/clicommand/env_unset.go index 4f57e5e99f..cb9dce195f 100644 --- a/clicommand/env_unset.go +++ b/clicommand/env_unset.go @@ -67,7 +67,7 @@ var EnvUnsetCommand = cli.Command{ func envUnsetAction(c *cli.Context) error { ctx := context.Background() - ctx, cfg, l, _, done := setupLoggerAndConfig[EnvUnsetConfig](ctx, c) + ctx, cfg, _, _, done := setupLoggerAndConfig[EnvUnsetConfig](ctx, c) defer done() client, err := jobapi.NewDefaultClient(ctx) @@ -120,7 +120,7 @@ func envUnsetAction(c *cli.Context) error { unset, err := client.EnvDelete(ctx, del) if err != nil { - l.Error("couldn't unset the job executor environment variables: %v", err) + return fmt.Errorf("couldn't unset the job executor environment variables: %w", err) } switch cfg.OutputFormat { From 16c6049658ed07aae3db76a9d853d72a92656eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jendrysik?= Date: Mon, 27 Apr 2026 12:35:15 +0200 Subject: [PATCH 06/57] Add setCheckoutEnv Add setCheckoutEnv helper to export checkout-related vars when no-checkout-override=false --- agent/job_runner.go | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/agent/job_runner.go b/agent/job_runner.go index 48023894e3..f13699120d 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -428,6 +428,14 @@ func (r *JobRunner) createEnvironment(ctx context.Context) ([]string, error) { } env[name] = value } + setCheckoutEnv := func(name, value string) { + if !r.conf.AgentConfiguration.NoCheckoutOverride { + if _, exists := env[name]; exists { + return + } + } + setEnv(name, value) + } // Write out the job environment to file: // - envShellFile: in k="v" format, with newlines escaped. If the @@ -558,25 +566,25 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setEnv("BUILDKITE_CONFIG_PATH", r.conf.AgentConfiguration.ConfigPath) setEnv("BUILDKITE_BUILD_PATH", r.conf.AgentConfiguration.BuildPath) setEnv("BUILDKITE_SOCKETS_PATH", r.conf.AgentConfiguration.SocketsPath) - setEnv("BUILDKITE_GIT_MIRRORS_PATH", r.conf.AgentConfiguration.GitMirrorsPath) - setEnv("BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", fmt.Sprint(r.conf.AgentConfiguration.GitMirrorsSkipUpdate)) + setCheckoutEnv("BUILDKITE_GIT_MIRRORS_PATH", r.conf.AgentConfiguration.GitMirrorsPath) + setCheckoutEnv("BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", fmt.Sprint(r.conf.AgentConfiguration.GitMirrorsSkipUpdate)) setEnv("BUILDKITE_HOOKS_PATH", r.conf.AgentConfiguration.HooksPath) setEnv("BUILDKITE_ADDITIONAL_HOOKS_PATHS", strings.Join(r.conf.AgentConfiguration.AdditionalHooksPaths, ",")) setEnv("BUILDKITE_PLUGINS_PATH", r.conf.AgentConfiguration.PluginsPath) - setEnv("BUILDKITE_SSH_KEYSCAN", fmt.Sprint(r.conf.AgentConfiguration.SSHKeyscan)) + setCheckoutEnv("BUILDKITE_SSH_KEYSCAN", fmt.Sprint(r.conf.AgentConfiguration.SSHKeyscan)) // Disable cloning submodules if specified in Agent config as precedence // else allow pipeline/step env to control it via BUILDKITE_GIT_SUBMODULES if !r.conf.AgentConfiguration.GitSubmodules { - setEnv("BUILDKITE_GIT_SUBMODULES", "false") + setCheckoutEnv("BUILDKITE_GIT_SUBMODULES", "false") } // Allow BUILDKITE_SKIP_CHECKOUT to be enabled either by agent config // or by pipeline/step env // This is here now to make it ready for if/when we add skip_checkout to the core app if r.conf.AgentConfiguration.SkipCheckout { - setEnv("BUILDKITE_SKIP_CHECKOUT", "true") + setCheckoutEnv("BUILDKITE_SKIP_CHECKOUT", "true") } if r.conf.AgentConfiguration.GitSkipFetchExistingCommits { - setEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", "true") + setCheckoutEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", "true") } setEnv("BUILDKITE_NO_CHECKOUT_OVERRIDE", fmt.Sprint(r.conf.AgentConfiguration.NoCheckoutOverride)) setEnv("BUILDKITE_CHECKOUT_ATTEMPTS", strconv.Itoa(r.conf.AgentConfiguration.CheckoutAttempts)) @@ -589,14 +597,14 @@ BUILDKITE_AGENT_JWKS_KEY_ID` } setEnv("BUILDKITE_LOCAL_HOOKS_ENABLED", fmt.Sprint(r.conf.AgentConfiguration.LocalHooksEnabled)) - setEnv("BUILDKITE_GIT_CHECKOUT_FLAGS", r.conf.AgentConfiguration.GitCheckoutFlags) - setEnv("BUILDKITE_GIT_CLONE_FLAGS", r.conf.AgentConfiguration.GitCloneFlags) - setEnv("BUILDKITE_GIT_FETCH_FLAGS", r.conf.AgentConfiguration.GitFetchFlags) - setEnv("BUILDKITE_GIT_CLONE_MIRROR_FLAGS", r.conf.AgentConfiguration.GitCloneMirrorFlags) + setCheckoutEnv("BUILDKITE_GIT_CHECKOUT_FLAGS", r.conf.AgentConfiguration.GitCheckoutFlags) + setCheckoutEnv("BUILDKITE_GIT_CLONE_FLAGS", r.conf.AgentConfiguration.GitCloneFlags) + setCheckoutEnv("BUILDKITE_GIT_FETCH_FLAGS", r.conf.AgentConfiguration.GitFetchFlags) + setCheckoutEnv("BUILDKITE_GIT_CLONE_MIRROR_FLAGS", r.conf.AgentConfiguration.GitCloneMirrorFlags) setEnv("BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", r.conf.AgentConfiguration.GitMirrorCheckoutMode) - setEnv("BUILDKITE_GIT_CLEAN_FLAGS", r.conf.AgentConfiguration.GitCleanFlags) - setEnv("BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitMirrorsLockTimeout)) - setEnv("BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", strings.Join(r.conf.AgentConfiguration.GitSubmoduleCloneConfig, ",")) + setCheckoutEnv("BUILDKITE_GIT_CLEAN_FLAGS", r.conf.AgentConfiguration.GitCleanFlags) + setCheckoutEnv("BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitMirrorsLockTimeout)) + setCheckoutEnv("BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", strings.Join(r.conf.AgentConfiguration.GitSubmoduleCloneConfig, ",")) setEnv("BUILDKITE_SHELL", r.conf.AgentConfiguration.Shell) setEnv("BUILDKITE_HOOKS_SHELL", r.conf.AgentConfiguration.HooksShell) From 435510bba678dd73a698a6b7d86e5d03136fa33e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jendrysik?= Date: Mon, 27 Apr 2026 12:44:55 +0200 Subject: [PATCH 07/57] Add integration tests --- .../job_environment_integration_test.go | 124 ++++++++++++++++++ .../job/integration/hooks_integration_test.go | 72 ++++++++++ .../integration/secrets_integration_test.go | 71 ++++++++++ 3 files changed, 267 insertions(+) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index cb20646e8e..1f57e28c92 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -2,6 +2,7 @@ package integration import ( "context" + "slices" "strings" "testing" @@ -229,3 +230,126 @@ func TestBuildkiteRequestHeaders(t *testing.T) { t.Fatalf("runJob() error = %v", err) } } + +func TestCheckoutScopedJobEnvOverrideHonorsNoCheckoutOverride(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + varName string + jobEnv map[string]string + agentCfg agent.AgentConfiguration + wantEnvValue string + wantIgnoredEnvVars []string + }{ + { + name: "disabled_allows_job_env_to_override_clone_flags", + varName: "BUILDKITE_GIT_CLONE_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CLONE_FLAGS": "--no-tags", + }, + agentCfg: agent.AgentConfiguration{ + GitCloneFlags: "--mirror", + }, + wantEnvValue: "--no-tags", + }, + { + name: "enabled_locks_clone_flags_to_agent_config", + varName: "BUILDKITE_GIT_CLONE_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CLONE_FLAGS": "--no-tags", + }, + agentCfg: agent.AgentConfiguration{ + GitCloneFlags: "--mirror", + NoCheckoutOverride: true, + }, + wantEnvValue: "--mirror", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_FLAGS"}, + }, + { + name: "disabled_allows_job_env_to_enable_submodules", + varName: "BUILDKITE_GIT_SUBMODULES", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SUBMODULES": "true", + }, + agentCfg: agent.AgentConfiguration{ + GitSubmodules: false, + }, + wantEnvValue: "true", + }, + { + name: "enabled_locks_submodules_to_agent_config", + varName: "BUILDKITE_GIT_SUBMODULES", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SUBMODULES": "true", + }, + agentCfg: agent.AgentConfiguration{ + GitSubmodules: false, + NoCheckoutOverride: true, + }, + wantEnvValue: "false", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SUBMODULES"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + jobEnv := map[string]string{ + "BUILDKITE_COMMAND": "echo hello world", + } + for k, v := range tc.jobEnv { + jobEnv[k] = v + } + + job := &api.Job{ + ID: "my-job-id", + ChunksMaxSizeBytes: 1024, + Env: jobEnv, + Token: "bkaj_job-token", + } + + mb := mockBootstrap(t) + defer mb.CheckAndClose(t) //nolint:errcheck // bintest logs to t + + mb.Expect().Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + if got, want := c.GetEnv(tc.varName), tc.wantEnvValue; got != want { + t.Errorf("c.GetEnv(%s) = %q, want %q", tc.varName, got, want) + c.Exit(1) + return + } + + ignored := strings.Split(strings.TrimSpace(c.GetEnv("BUILDKITE_IGNORED_ENV")), ",") + for _, wantIgnored := range tc.wantIgnoredEnvVars { + if !slices.Contains(ignored, wantIgnored) { + t.Errorf("BUILDKITE_IGNORED_ENV = %q, want it to contain %q", c.GetEnv("BUILDKITE_IGNORED_ENV"), wantIgnored) + c.Exit(1) + return + } + } + if len(tc.wantIgnoredEnvVars) == 0 && c.GetEnv("BUILDKITE_IGNORED_ENV") != "" { + t.Errorf("BUILDKITE_IGNORED_ENV = %q, want empty", c.GetEnv("BUILDKITE_IGNORED_ENV")) + c.Exit(1) + return + } + + c.Exit(0) + }) + + e := createTestAgentEndpoint() + server := e.server() + defer server.Close() + + if err := runJob(t, ctx, testRunJobConfig{ + job: job, + server: server, + agentCfg: tc.agentCfg, + mockBootstrap: mb, + }); err != nil { + t.Fatalf("runJob() error = %v", err) + } + }) + } +} diff --git a/internal/job/integration/hooks_integration_test.go b/internal/job/integration/hooks_integration_test.go index 6e8a3d6bc4..963b6927c4 100644 --- a/internal/job/integration/hooks_integration_test.go +++ b/internal/job/integration/hooks_integration_test.go @@ -120,6 +120,78 @@ func TestHooksCanUnsetEnvironmentVariables(t *testing.T) { tester.RunAndCheck(t, "MY_CUSTOM_ENV=1") } +func TestEnvironmentHookNoCheckoutOverride(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + noCheckoutOverride bool + wantSkipCheckoutEnv string + wantBlockedWarning bool + }{ + { + name: "disabled_allows_mutation", + wantSkipCheckoutEnv: "true", + }, + { + name: "enabled_blocks_mutation", + noCheckoutOverride: true, + wantBlockedWarning: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tester, err := NewExecutorTester(mainCtx) + if err != nil { + t.Fatalf("NewExecutorTester() error = %v", err) + } + defer tester.Close() + + filename := "environment" + script := []string{ + "#!/usr/bin/env bash", + "export BUILDKITE_SKIP_CHECKOUT=true", + } + if runtime.GOOS == "windows" { + filename = "environment.bat" + script = []string{ + "@echo off", + "set BUILDKITE_SKIP_CHECKOUT=true", + } + } + + if err := os.WriteFile(filepath.Join(tester.HooksDir, filename), []byte(strings.Join(script, "\n")), 0o700); err != nil { + t.Fatalf("os.WriteFile(%q, script, 0o700) = %v", filename, err) + } + + tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + if got, want := c.GetEnv("BUILDKITE_SKIP_CHECKOUT"), tc.wantSkipCheckoutEnv; got != want { + fmt.Fprintf(c.Stderr, "Expected BUILDKITE_SKIP_CHECKOUT=%q, got %q\n", want, got) + c.Exit(1) + return + } + c.Exit(0) + }) + + env := []string{} + if tc.noCheckoutOverride { + env = append(env, "BUILDKITE_NO_CHECKOUT_OVERRIDE=true") + } + + tester.RunAndCheck(t, env...) + + containsWarning := strings.Contains(tester.Output, "env vars were blocked") && + strings.Contains(tester.Output, "BUILDKITE_SKIP_CHECKOUT") + if containsWarning != tc.wantBlockedWarning { + t.Fatalf("blocked warning presence = %t, want %t\noutput: %s", containsWarning, tc.wantBlockedWarning, tester.Output) + } + }) + } +} + func TestDirectoryPassesBetweenHooks(t *testing.T) { t.Parallel() diff --git a/internal/job/integration/secrets_integration_test.go b/internal/job/integration/secrets_integration_test.go index b395111560..054e570141 100644 --- a/internal/job/integration/secrets_integration_test.go +++ b/internal/job/integration/secrets_integration_test.go @@ -565,3 +565,74 @@ func TestSecretsIntegration_ProtectedEnvironmentVariableRejection(t *testing.T) } } } + +func TestSecretsIntegration_NoCheckoutOverride(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + noCheckoutOverride bool + wantErr bool + }{ + {name: "disabled_allows_checkout_scoped_secret"}, + {name: "enabled_rejects_checkout_locked_secret", noCheckoutOverride: true, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tester, err := NewExecutorTester(mainCtx) + if err != nil { + t.Fatalf("setting up executor tester: %v", err) + } + defer tester.Close() + + apiServer := setupSecretsAPIServer(t, map[string]string{"CLONE_FLAGS_SECRET": "--mirror"}) + defer apiServer.Close() + + secretsJSON, err := json.Marshal([]pipeline.Secret{{ + Key: "CLONE_FLAGS_SECRET", + EnvironmentVariable: "BUILDKITE_GIT_CLONE_FLAGS", + }}) + if err != nil { + t.Fatalf("marshaling secrets: %v", err) + } + + if !tc.wantErr { + tester.ExpectGlobalHook("command").AndCallFunc(func(c *bintest.Call) { + if got, want := c.GetEnv("BUILDKITE_GIT_CLONE_FLAGS"), "--mirror"; got != want { + fmt.Fprintf(c.Stderr, "Expected BUILDKITE_GIT_CLONE_FLAGS=%q, got %q\n", want, got) + c.Exit(1) + return + } + c.Exit(0) + }) + } + + env := []string{ + fmt.Sprintf("BUILDKITE_SECRETS_CONFIG=%s", string(secretsJSON)), + fmt.Sprintf("BUILDKITE_AGENT_ENDPOINT=%s", apiServer.URL), + } + if tc.noCheckoutOverride { + env = append(env, "BUILDKITE_NO_CHECKOUT_OVERRIDE=true") + } + + err = tester.Run(t, env...) + if tc.wantErr { + if err == nil { + t.Fatalf("expected job to fail due to checkout-locked secret mapping, but it succeeded. Full output: %s", tester.Output) + } + if !strings.Contains(tester.Output, "checkout-locked environment variable") || !strings.Contains(tester.Output, "BUILDKITE_GIT_CLONE_FLAGS") { + t.Fatalf("expected output to mention checkout-locked env rejection, got: %s", tester.Output) + } + return + } + + if err != nil { + t.Fatalf("running executor tester: %v", err) + } + tester.CheckMocks(t) + }) + } +} From b0be76acac2534805ed8e269c8094e8534f1108d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jendrysik?= Date: Tue, 28 Apr 2026 14:05:32 +0200 Subject: [PATCH 08/57] Add comment re checkoutOverrideScope --- env/protected.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/env/protected.go b/env/protected.go index 3cdbafc19f..dc24ab17e9 100644 --- a/env/protected.go +++ b/env/protected.go @@ -63,6 +63,11 @@ var protectedEnv = map[string]protection{ "BUILDKITE_SHELL": {}, } +// checkoutOverrideScope contains checkout-related vars that remain mutable in +// hooks, plugins, Job API, and secrets by default so jobs can tailor checkout +// behavior. When checkout override is enabled, those same vars become locked so +// agent checkout config wins and git flags cannot be used to undermine +// no-command-eval. var checkoutOverrideScope = map[string]struct{}{ "BUILDKITE_GIT_CHECKOUT_FLAGS": {}, "BUILDKITE_GIT_CLEAN_FLAGS": {}, From 4781e0b252700395b556f2d1acf1e7e5087813fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jendrysik?= Date: Tue, 28 Apr 2026 15:58:03 +0200 Subject: [PATCH 09/57] Set no-checkout-override=true when command-eval disabled --- clicommand/agent_start.go | 8 ++++++++ clicommand/bootstrap.go | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index e57aea98c0..a0d3eef0ce 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -225,6 +225,12 @@ type AgentStartConfig struct { DisconnectAfterJobTimeout int `cli:"disconnect-after-job-timeout" deprecated:"Use disconnect-after-idle-timeout instead"` } +func (cfg *AgentStartConfig) lockCheckoutWhenCommandEvalDisabled() { + if cfg.NoCommandEval { + cfg.NoCheckoutOverride = true + } +} + func (asc AgentStartConfig) Features(ctx context.Context) []string { if asc.NoFeatureReporting { return []string{} @@ -898,6 +904,8 @@ var AgentStartCommand = cli.Command{ cfg.NoPlugins = true } + cfg.lockCheckoutWhenCommandEvalDisabled() + // Guess the shell if none is provided if cfg.Shell == "" { cfg.Shell = DefaultShell() diff --git a/clicommand/bootstrap.go b/clicommand/bootstrap.go index ddc3d7daf8..588cf44445 100644 --- a/clicommand/bootstrap.go +++ b/clicommand/bootstrap.go @@ -116,6 +116,12 @@ type BootstrapConfig struct { CheckoutAttempts int `cli:"checkout-attempts"` } +func (cfg *BootstrapConfig) lockCheckoutWhenCommandEvalDisabled() { + if !cfg.CommandEval { + cfg.NoCheckoutOverride = true + } +} + var BootstrapCommand = cli.Command{ Name: "bootstrap", Usage: "Harness used internally by the agent to run jobs as subprocesses", @@ -419,6 +425,8 @@ var BootstrapCommand = cli.Command{ return fmt.Errorf("while parsing trace context encoding: %v", err) } + cfg.lockCheckoutWhenCommandEvalDisabled() + // Configure the bootstraper bootstrap := job.New(job.ExecutorConfig{ AgentName: cfg.AgentName, From c588804064310e0e326e060d5111005bdbac4cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jendrysik?= Date: Wed, 29 Apr 2026 08:57:14 +0200 Subject: [PATCH 10/57] Add test for no command eval enabling no checkout override --- clicommand/agent_start_test.go | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/clicommand/agent_start_test.go b/clicommand/agent_start_test.go index 2ca933d496..2387e807b8 100644 --- a/clicommand/agent_start_test.go +++ b/clicommand/agent_start_test.go @@ -223,3 +223,69 @@ func TestAgentStartJobAcquisitionRejected_ExitCode27(t *testing.T) { assert.True(t, errors.As(cliErr, &exitErr), "Expected cli.ExitError, got: %v", cliErr) assert.Equal(t, 27, exitErr.ExitCode(), "Expected exit code 27 for job acquisition rejected, got: %d", exitErr.ExitCode()) } + +func TestNoCheckoutOverrideEnabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg AgentStartConfig + bCfg BootstrapConfig + kind string + want bool + }{ + { + name: "agent_start_explicit_flag", + cfg: AgentStartConfig{NoCheckoutOverride: true}, + kind: "agent", + want: true, + }, + { + name: "agent_start_no_command_eval_forces_lock", + cfg: AgentStartConfig{NoCommandEval: true}, + kind: "agent", + want: true, + }, + { + name: "agent_start_defaults_unlocked", + cfg: AgentStartConfig{}, + kind: "agent", + want: false, + }, + { + name: "bootstrap_explicit_flag", + bCfg: BootstrapConfig{NoCheckoutOverride: true, CommandEval: true}, + kind: "bootstrap", + want: true, + }, + { + name: "bootstrap_command_eval_disabled_forces_lock", + bCfg: BootstrapConfig{CommandEval: false}, + kind: "bootstrap", + want: true, + }, + { + name: "bootstrap_defaults_unlocked", + bCfg: BootstrapConfig{CommandEval: true}, + kind: "bootstrap", + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + switch tc.kind { + case "agent": + tc.cfg.lockCheckoutWhenCommandEvalDisabled() + assert.Equal(t, tc.want, tc.cfg.NoCheckoutOverride) + case "bootstrap": + tc.bCfg.lockCheckoutWhenCommandEvalDisabled() + assert.Equal(t, tc.want, tc.bCfg.NoCheckoutOverride) + default: + t.Fatalf("unknown test kind %q", tc.kind) + } + }) + } +} From 73973f7e1c6bccb5b496c6c4b6dfbde000841770 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Thu, 7 May 2026 14:03:05 -0400 Subject: [PATCH 11/57] Add skip checkout test cases for no-checkout-override --- .../job_environment_integration_test.go | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index 1f57e28c92..47ad41bac6 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -290,6 +290,30 @@ func TestCheckoutScopedJobEnvOverrideHonorsNoCheckoutOverride(t *testing.T) { wantEnvValue: "false", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SUBMODULES"}, }, + { + name: "disabled_allows_job_env_to_override_skip_checkout", + varName: "BUILDKITE_SKIP_CHECKOUT", + jobEnv: map[string]string{ + "BUILDKITE_SKIP_CHECKOUT": "false", + }, + agentCfg: agent.AgentConfiguration{ + SkipCheckout: true, + }, + wantEnvValue: "false", + }, + { + name: "enabled_locks_skip_checkout_to_agent_config", + varName: "BUILDKITE_SKIP_CHECKOUT", + jobEnv: map[string]string{ + "BUILDKITE_SKIP_CHECKOUT": "false", + }, + agentCfg: agent.AgentConfiguration{ + SkipCheckout: true, + NoCheckoutOverride: true, + }, + wantEnvValue: "true", + wantIgnoredEnvVars: []string{"BUILDKITE_SKIP_CHECKOUT"}, + }, } for _, tc := range tests { From f46020dfbe7926dc0327361fb5aaa7103e5df524 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Thu, 7 May 2026 14:12:20 -0400 Subject: [PATCH 12/57] Fix golangci-lint errors --- internal/job/integration/hooks_integration_test.go | 2 +- internal/job/integration/secrets_integration_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/job/integration/hooks_integration_test.go b/internal/job/integration/hooks_integration_test.go index d518eae315..283b485a85 100644 --- a/internal/job/integration/hooks_integration_test.go +++ b/internal/job/integration/hooks_integration_test.go @@ -169,7 +169,7 @@ func TestEnvironmentHookNoCheckoutOverride(t *testing.T) { tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { if got, want := c.GetEnv("BUILDKITE_SKIP_CHECKOUT"), tc.wantSkipCheckoutEnv; got != want { - fmt.Fprintf(c.Stderr, "Expected BUILDKITE_SKIP_CHECKOUT=%q, got %q\n", want, got) + _, _ = fmt.Fprintf(c.Stderr, "Expected BUILDKITE_SKIP_CHECKOUT=%q, got %q\n", want, got) c.Exit(1) return } diff --git a/internal/job/integration/secrets_integration_test.go b/internal/job/integration/secrets_integration_test.go index 6409884e17..8599df8a7d 100644 --- a/internal/job/integration/secrets_integration_test.go +++ b/internal/job/integration/secrets_integration_test.go @@ -600,7 +600,7 @@ func TestSecretsIntegration_NoCheckoutOverride(t *testing.T) { if !tc.wantErr { tester.ExpectGlobalHook("command").AndCallFunc(func(c *bintest.Call) { if got, want := c.GetEnv("BUILDKITE_GIT_CLONE_FLAGS"), "--mirror"; got != want { - fmt.Fprintf(c.Stderr, "Expected BUILDKITE_GIT_CLONE_FLAGS=%q, got %q\n", want, got) + _, _ = fmt.Fprintf(c.Stderr, "Expected BUILDKITE_GIT_CLONE_FLAGS=%q, got %q\n", want, got) c.Exit(1) return } From c8d072cafe860265245bbf54daeae2c5e5602754 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 9 Jun 2026 14:55:21 -0400 Subject: [PATCH 13/57] Make checkout infra vars agent-only --- .../job_environment_integration_test.go | 136 ++++++++++++++++++ agent/job_runner.go | 11 +- env/protected.go | 11 +- env/protected_test.go | 12 +- 4 files changed, 156 insertions(+), 14 deletions(-) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index a6e3b1ad7f..06cabd8509 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -378,6 +378,142 @@ func TestCheckoutScopedJobEnvOverrideHonorsNoCheckoutOverride(t *testing.T) { } } +func TestCheckoutInfraVarsAreAgentAuthoritative(t *testing.T) { + t.Parallel() + + // SSH_KEYSCAN, GIT_MIRRORS_PATH and GIT_MIRRORS_LOCK_TIMEOUT are agent-only: + // job env cannot override them even with no-checkout-override disabled. + tests := []struct { + name string + varName string + jobEnvValue string + agentCfg agent.AgentConfiguration + wantEnvValue string + }{ + { + name: "ssh_keyscan", + varName: "BUILDKITE_SSH_KEYSCAN", + jobEnvValue: "false", + agentCfg: agent.AgentConfiguration{SSHKeyscan: true}, + wantEnvValue: "true", + }, + { + name: "git_mirrors_path", + varName: "BUILDKITE_GIT_MIRRORS_PATH", + jobEnvValue: "/tmp/attacker-mirrors", + agentCfg: agent.AgentConfiguration{GitMirrorsPath: "/agent/mirrors"}, + wantEnvValue: "/agent/mirrors", + }, + { + name: "git_mirrors_lock_timeout", + varName: "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", + jobEnvValue: "1", + agentCfg: agent.AgentConfiguration{GitMirrorsLockTimeout: 300}, + wantEnvValue: "300", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + job := &api.Job{ + ID: "my-job-id", + ChunksMaxSizeBytes: 1024, + Env: map[string]string{ + "BUILDKITE_COMMAND": "echo hello world", + tc.varName: tc.jobEnvValue, + }, + Token: "bkaj_job-token", + } + + mb := mockBootstrap(t) + defer mb.CheckAndClose(t) //nolint:errcheck // bintest logs to t + + mb.Expect().Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + if got, want := c.GetEnv(tc.varName), tc.wantEnvValue; got != want { + t.Errorf("c.GetEnv(%s) = %q, want %q", tc.varName, got, want) + c.Exit(1) + return + } + + ignored := strings.Split(strings.TrimSpace(c.GetEnv("BUILDKITE_IGNORED_ENV")), ",") + if !slices.Contains(ignored, tc.varName) { + t.Errorf("BUILDKITE_IGNORED_ENV = %q, want it to contain %q", c.GetEnv("BUILDKITE_IGNORED_ENV"), tc.varName) + c.Exit(1) + return + } + + c.Exit(0) + }) + + e := createTestAgentEndpoint() + server := e.server() + defer server.Close() + + if err := runJob(t, ctx, testRunJobConfig{ + job: job, + server: server, + agentCfg: tc.agentCfg, + mockBootstrap: mb, + }); err != nil { + t.Fatalf("runJob() error = %v", err) + } + }) + } +} + +func TestNoCheckoutOverrideFlagIgnoresJobEnvOverride(t *testing.T) { + t.Parallel() + + // The agent's no-checkout-override setting is authoritative: a job that + // supplies BUILDKITE_NO_CHECKOUT_OVERRIDE cannot turn the lock off. + ctx := context.Background() + job := &api.Job{ + ID: "my-job-id", + ChunksMaxSizeBytes: 1024, + Env: map[string]string{ + "BUILDKITE_COMMAND": "echo hello world", + "BUILDKITE_NO_CHECKOUT_OVERRIDE": "false", + }, + Token: "bkaj_job-token", + } + + mb := mockBootstrap(t) + defer mb.CheckAndClose(t) //nolint:errcheck // bintest logs to t + + mb.Expect().Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + if got, want := c.GetEnv("BUILDKITE_NO_CHECKOUT_OVERRIDE"), "true"; got != want { + t.Errorf("c.GetEnv(BUILDKITE_NO_CHECKOUT_OVERRIDE) = %q, want %q", got, want) + c.Exit(1) + return + } + + ignored := strings.Split(strings.TrimSpace(c.GetEnv("BUILDKITE_IGNORED_ENV")), ",") + if !slices.Contains(ignored, "BUILDKITE_NO_CHECKOUT_OVERRIDE") { + t.Errorf("BUILDKITE_IGNORED_ENV = %q, want it to contain BUILDKITE_NO_CHECKOUT_OVERRIDE", c.GetEnv("BUILDKITE_IGNORED_ENV")) + c.Exit(1) + return + } + + c.Exit(0) + }) + + e := createTestAgentEndpoint() + server := e.server() + defer server.Close() + + if err := runJob(t, ctx, testRunJobConfig{ + job: job, + server: server, + agentCfg: agent.AgentConfiguration{NoCheckoutOverride: true}, + mockBootstrap: mb, + }); err != nil { + t.Fatalf("runJob() error = %v", err) + } +} + func TestArtifactUploadConcurrencyFromAgentConfigIsSet(t *testing.T) { t.Parallel() diff --git a/agent/job_runner.go b/agent/job_runner.go index b3869e5531..531211d18d 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -599,14 +599,15 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setEnv("BUILDKITE_CONFIG_PATH", r.conf.AgentConfiguration.ConfigPath) setEnv("BUILDKITE_BUILD_PATH", r.conf.AgentConfiguration.BuildPath) setEnv("BUILDKITE_SOCKETS_PATH", r.conf.AgentConfiguration.SocketsPath) - setCheckoutEnv("BUILDKITE_GIT_MIRRORS_PATH", r.conf.AgentConfiguration.GitMirrorsPath) + setEnv("BUILDKITE_GIT_MIRRORS_PATH", r.conf.AgentConfiguration.GitMirrorsPath) setCheckoutEnv("BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", fmt.Sprint(r.conf.AgentConfiguration.GitMirrorsSkipUpdate)) setEnv("BUILDKITE_HOOKS_PATH", r.conf.AgentConfiguration.HooksPath) setEnv("BUILDKITE_ADDITIONAL_HOOKS_PATHS", strings.Join(r.conf.AgentConfiguration.AdditionalHooksPaths, ",")) setEnv("BUILDKITE_PLUGINS_PATH", r.conf.AgentConfiguration.PluginsPath) - setCheckoutEnv("BUILDKITE_SSH_KEYSCAN", fmt.Sprint(r.conf.AgentConfiguration.SSHKeyscan)) - // Disable cloning submodules if specified in Agent config as precedence - // else allow pipeline/step env to control it via BUILDKITE_GIT_SUBMODULES + setEnv("BUILDKITE_SSH_KEYSCAN", fmt.Sprint(r.conf.AgentConfiguration.SSHKeyscan)) + // Default submodules off when disabled in agent config, but let pipeline/ + // step env override via BUILDKITE_GIT_SUBMODULES unless no-checkout-override + // is set. if !r.conf.AgentConfiguration.GitSubmodules { setCheckoutEnv("BUILDKITE_GIT_SUBMODULES", "false") } @@ -636,7 +637,7 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setCheckoutEnv("BUILDKITE_GIT_CLONE_MIRROR_FLAGS", r.conf.AgentConfiguration.GitCloneMirrorFlags) setEnv("BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", r.conf.AgentConfiguration.GitMirrorCheckoutMode) setCheckoutEnv("BUILDKITE_GIT_CLEAN_FLAGS", r.conf.AgentConfiguration.GitCleanFlags) - setCheckoutEnv("BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitMirrorsLockTimeout)) + setEnv("BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitMirrorsLockTimeout)) if r.conf.AgentConfiguration.GitCheckoutTimeout > 0 { setEnv("BUILDKITE_GIT_CHECKOUT_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitCheckoutTimeout)) } diff --git a/env/protected.go b/env/protected.go index 2a230374c1..7c531cd99c 100644 --- a/env/protected.go +++ b/env/protected.go @@ -52,6 +52,8 @@ var protectedEnv = map[string]protection{ "BUILDKITE_CONFIG_PATH": {}, "BUILDKITE_CONTAINER_COUNT": {}, "BUILDKITE_GIT_COMMIT_VERIFICATION": {}, + "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT": {}, + "BUILDKITE_GIT_MIRRORS_PATH": {}, "BUILDKITE_HOOKS_PATH": {}, "BUILDKITE_HOOKS_SHELL": {}, "BUILDKITE_KUBERNETES_EXEC": {}, @@ -63,27 +65,26 @@ var protectedEnv = map[string]protection{ "BUILDKITE_REFSPEC": {mutableFromWithinJob: true}, "BUILDKITE_REPO": {mutableFromWithinJob: true}, "BUILDKITE_SHELL": {}, + "BUILDKITE_SSH_KEYSCAN": {}, } // checkoutOverrideScope contains checkout-related vars that remain mutable in // hooks, plugins, Job API, and secrets by default so jobs can tailor checkout // behavior. When checkout override is enabled, those same vars become locked so // agent checkout config wins and git flags cannot be used to undermine -// no-command-eval. +// no-command-eval. Vars here must not also appear in protectedEnv; the two maps +// are disjoint. var checkoutOverrideScope = map[string]struct{}{ "BUILDKITE_GIT_CHECKOUT_FLAGS": {}, "BUILDKITE_GIT_CLEAN_FLAGS": {}, "BUILDKITE_GIT_CLONE_FLAGS": {}, "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": {}, "BUILDKITE_GIT_FETCH_FLAGS": {}, - "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT": {}, - "BUILDKITE_GIT_MIRRORS_PATH": {}, "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": {}, "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": {}, "BUILDKITE_GIT_SUBMODULES": {}, "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG": {}, "BUILDKITE_SKIP_CHECKOUT": {}, - "BUILDKITE_SSH_KEYSCAN": {}, } // IsProtected reports whether the environment variable is write-protected when @@ -104,6 +105,8 @@ func IsProtectedFromWithinJob(name string) bool { return !prot.mutableFromWithinJob } +// IsCheckoutOverrideScoped reports whether name is a checkout-related env var +// that is write-locked when no-checkout-override is enabled. func IsCheckoutOverrideScoped(name string) bool { _, exists := checkoutOverrideScope[normalizeKeyName(name)] return exists diff --git a/env/protected_test.go b/env/protected_test.go index 9653e3429a..8ac85ca9ea 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -17,6 +17,8 @@ func TestProtectedEnv(t *testing.T) { "BUILDKITE_COMMAND_EVAL", "BUILDKITE_CONFIG_PATH", "BUILDKITE_CONTAINER_COUNT", + "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", + "BUILDKITE_GIT_MIRRORS_PATH", "BUILDKITE_HOOKS_PATH", "BUILDKITE_KUBERNETES_EXEC", "BUILDKITE_LOCAL_HOOKS_ENABLED", @@ -25,6 +27,7 @@ func TestProtectedEnv(t *testing.T) { "BUILDKITE_PLUGINS_PATH", "BUILDKITE_SHELL", "BUILDKITE_HOOKS_SHELL", + "BUILDKITE_SSH_KEYSCAN", } // Verify that all expected variables are protected @@ -43,7 +46,6 @@ func TestProtectedEnv(t *testing.T) { "BUILDKITE_GIT_CLONE_FLAGS", "BUILDKITE_GIT_SUBMODULES", "BUILDKITE_SKIP_CHECKOUT", - "BUILDKITE_SSH_KEYSCAN", "BUILDKITE_BRANCH", // This is a standard build env var, not protected "BUILDKITE_COMMIT", // This is a standard build env var, not protected "BUILDKITE_MESSAGE", // This is a standard build env var, not protected @@ -76,13 +78,10 @@ func TestCheckoutOverrideScope(t *testing.T) { "BUILDKITE_GIT_CLEAN_FLAGS", "BUILDKITE_GIT_FETCH_FLAGS", "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", - "BUILDKITE_GIT_MIRRORS_PATH", - "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", "BUILDKITE_GIT_SUBMODULES", "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", "BUILDKITE_SKIP_CHECKOUT", - "BUILDKITE_SSH_KEYSCAN", } for _, envVar := range scoped { @@ -95,15 +94,18 @@ func TestCheckoutOverrideScope(t *testing.T) { "BUILDKITE_GIT_CLONE_FLAGS", "BUILDKITE_GIT_SUBMODULES", "BUILDKITE_SKIP_CHECKOUT", - "BUILDKITE_SSH_KEYSCAN", } { if got := IsProtected(envVar); got { t.Errorf("IsProtected(%q) = true, want false", envVar) } } + // Checkout infra vars are agent-only: protected, never in the override scope. for _, envVar := range []string{ "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", + "BUILDKITE_GIT_MIRRORS_PATH", + "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", + "BUILDKITE_SSH_KEYSCAN", "BUILDKITE_COMMAND_EVAL", "MY_CUSTOM_VAR", } { From 12273d9ecc747dd3f66927326196250d44bee0ec Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 9 Jun 2026 14:55:21 -0400 Subject: [PATCH 14/57] Test no-checkout-override blocks hooks and plugins --- .../job/integration/hooks_integration_test.go | 50 ++++++++++++ .../integration/plugin_integration_test.go | 79 +++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/internal/job/integration/hooks_integration_test.go b/internal/job/integration/hooks_integration_test.go index 283b485a85..90c59ced0c 100644 --- a/internal/job/integration/hooks_integration_test.go +++ b/internal/job/integration/hooks_integration_test.go @@ -192,6 +192,56 @@ func TestEnvironmentHookNoCheckoutOverride(t *testing.T) { } } +func TestEnvironmentHookCannotDisableNoCheckoutOverride(t *testing.T) { + t.Parallel() + + // A hook must not be able to turn the lock off mid-job: exporting + // BUILDKITE_NO_CHECKOUT_OVERRIDE=false should be ignored, so a checkout- + // scoped var the same hook tries to set stays blocked. + tester, err := NewExecutorTester(mainCtx) + if err != nil { + t.Fatalf("NewExecutorTester() error = %v", err) + } + defer tester.Close() + + filename := "environment" + script := []string{ + "#!/usr/bin/env bash", + "export BUILDKITE_NO_CHECKOUT_OVERRIDE=false", + "export BUILDKITE_SKIP_CHECKOUT=true", + } + if runtime.GOOS == "windows" { + filename = "environment.bat" + script = []string{ + "@echo off", + "set BUILDKITE_NO_CHECKOUT_OVERRIDE=false", + "set BUILDKITE_SKIP_CHECKOUT=true", + } + } + + if err := os.WriteFile(filepath.Join(tester.HooksDir, filename), []byte(strings.Join(script, "\n")), 0o700); err != nil { + t.Fatalf("os.WriteFile(%q, script, 0o700) = %v", filename, err) + } + + tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + if got := c.GetEnv("BUILDKITE_SKIP_CHECKOUT"); got == "true" { + _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want the lock to stay on and block it\n", got) + c.Exit(1) + return + } + c.Exit(0) + }) + + tester.RunAndCheck(t, "BUILDKITE_NO_CHECKOUT_OVERRIDE=true") + + // Both the lock var itself and the scoped var should be reported as blocked. + for _, want := range []string{"BUILDKITE_NO_CHECKOUT_OVERRIDE", "BUILDKITE_SKIP_CHECKOUT"} { + if !strings.Contains(tester.Output, "env vars were blocked") || !strings.Contains(tester.Output, want) { + t.Fatalf("output did not report %q as blocked\noutput: %s", want, tester.Output) + } + } +} + func TestDirectoryPassesBetweenHooks(t *testing.T) { t.Parallel() diff --git a/internal/job/integration/plugin_integration_test.go b/internal/job/integration/plugin_integration_test.go index 344a48c563..2eeb3a364c 100644 --- a/internal/job/integration/plugin_integration_test.go +++ b/internal/job/integration/plugin_integration_test.go @@ -78,6 +78,85 @@ func TestRunningPlugins(t *testing.T) { tester.RunAndCheck(t, env...) } +func TestPluginEnvironmentHookNoCheckoutOverride(t *testing.T) { + t.Parallel() + + // A plugin's environment hook (e.g. git-clean) may set checkout flags by + // default, but no-checkout-override must block that mutation. + tests := []struct { + name string + noCheckoutOverride bool + wantBlocked bool + }{ + {name: "disabled_allows_plugin_to_override_clone_flags"}, + {name: "enabled_blocks_plugin_clone_flags_override", noCheckoutOverride: true, wantBlocked: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tester, err := NewExecutorTester(mainCtx) + if err != nil { + t.Fatalf("NewExecutorTester() error = %v", err) + } + defer tester.Close() + + // SKIP_CHECKOUT is a benign scoped var to assert on: setting it true + // just skips cloning, so the build still succeeds either way. Setting + // a real clone flag would break the checkout when the override lands. + hooks := map[string][]string{ + "environment": { + "#!/usr/bin/env bash", + "export BUILDKITE_SKIP_CHECKOUT=true", + }, + } + if runtime.GOOS == "windows" { + hooks = map[string][]string{ + "environment.bat": { + "@echo off", + "set BUILDKITE_SKIP_CHECKOUT=true", + }, + } + } + + p := createTestPlugin(t, hooks) + pluginJSON, err := p.ToJSON() + if err != nil { + t.Fatalf("testPlugin.ToJSON() error = %v", err) + } + + tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + got := c.GetEnv("BUILDKITE_SKIP_CHECKOUT") + if tc.wantBlocked && got == "true" { + _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want the plugin override to be blocked\n", got) + c.Exit(1) + return + } + if !tc.wantBlocked && got != "true" { + _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want %q\n", got, "true") + c.Exit(1) + return + } + c.Exit(0) + }) + + env := []string{"BUILDKITE_PLUGINS=" + pluginJSON} + if tc.noCheckoutOverride { + env = append(env, "BUILDKITE_NO_CHECKOUT_OVERRIDE=true") + } + + tester.RunAndCheck(t, env...) + + containsWarning := strings.Contains(tester.Output, "env vars were blocked") && + strings.Contains(tester.Output, "BUILDKITE_SKIP_CHECKOUT") + if containsWarning != tc.wantBlocked { + t.Fatalf("blocked warning presence = %t, want %t\noutput: %s", containsWarning, tc.wantBlocked, tester.Output) + } + }) + } +} + func TestExitCodesPropagateOutFromPlugins(t *testing.T) { t.Parallel() From cac36b92d369a12ff935848ed6e6a647175a6ac1 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 9 Jun 2026 14:55:21 -0400 Subject: [PATCH 15/57] Make WithNoCheckoutOverride parameterless --- internal/job/api.go | 2 +- jobapi/server.go | 4 ++-- jobapi/server_test.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/job/api.go b/internal/job/api.go index 2788f68b85..433eb81cd9 100644 --- a/internal/job/api.go +++ b/internal/job/api.go @@ -31,7 +31,7 @@ We'll continue to run your job, but you won't be able to use the Job API`) jobAPIOpts = append(jobAPIOpts, jobapi.WithDebug()) } if e.NoCheckoutOverride { - jobAPIOpts = append(jobAPIOpts, jobapi.WithNoCheckoutOverride(true)) + jobAPIOpts = append(jobAPIOpts, jobapi.WithNoCheckoutOverride()) } srv, token, err := jobapi.NewServer(e.shell.Logger, socketPath, e.shell.Env, e.redactors, jobAPIOpts...) if err != nil { diff --git a/jobapi/server.go b/jobapi/server.go index 90ccd0da7d..2adf039f99 100644 --- a/jobapi/server.go +++ b/jobapi/server.go @@ -22,9 +22,9 @@ func WithDebug() ServerOpts { } } -func WithNoCheckoutOverride(enabled bool) ServerOpts { +func WithNoCheckoutOverride() ServerOpts { return func(s *Server) { - s.noCheckoutOverride = enabled + s.noCheckoutOverride = true } } diff --git a/jobapi/server_test.go b/jobapi/server_test.go index 661cb4bd84..8612971e7d 100644 --- a/jobapi/server_test.go +++ b/jobapi/server_test.go @@ -401,7 +401,7 @@ func TestPatchEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *testi t.Parallel() environ := testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false") - srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride(true)) + srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride()) if err != nil { t.Fatalf("creating server: %v", err) } @@ -441,7 +441,7 @@ func TestDeleteEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *test t.Parallel() environ := testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false") - srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride(true)) + srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride()) if err != nil { t.Fatalf("creating server: %v", err) } From 404c510a7519a90705699274d614065c6b18aeb1 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 10 Jun 2026 15:00:25 -0400 Subject: [PATCH 16/57] Lock mirror checkout mode under no-checkout-override Mirror checkout mode affects the checkout but stayed mutable from hooks, plugins, the Job API, and secrets while the lock was on. Also add a disjointness test for the two protection maps and correct the scope test's protection assertions. --- env/protected.go | 1 + env/protected_test.go | 43 ++++++++++++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/env/protected.go b/env/protected.go index 7c531cd99c..94092cf925 100644 --- a/env/protected.go +++ b/env/protected.go @@ -81,6 +81,7 @@ var checkoutOverrideScope = map[string]struct{}{ "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": {}, "BUILDKITE_GIT_FETCH_FLAGS": {}, "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": {}, + "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE": {}, "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": {}, "BUILDKITE_GIT_SUBMODULES": {}, "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG": {}, diff --git a/env/protected_test.go b/env/protected_test.go index 8ac85ca9ea..1877c608af 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -79,38 +79,55 @@ func TestCheckoutOverrideScope(t *testing.T) { "BUILDKITE_GIT_FETCH_FLAGS", "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", + "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", "BUILDKITE_GIT_SUBMODULES", "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", "BUILDKITE_SKIP_CHECKOUT", } + // These vars are conditionally locked: scoped, write-protected only when + // no-checkout-override is enabled. (Disjointness from protectedEnv is + // covered by TestProtectedAndCheckoutScopeDisjoint.) for _, envVar := range scoped { if got := IsCheckoutOverrideScoped(envVar); !got { t.Errorf("IsCheckoutOverrideScoped(%q) = false, want true", envVar) } } + // Mirror infra is agent-only: always protected and never in the override + // scope, so a job can't relocate the mirror or stretch its lock timeout. for _, envVar := range []string{ - "BUILDKITE_GIT_CLONE_FLAGS", - "BUILDKITE_GIT_SUBMODULES", - "BUILDKITE_SKIP_CHECKOUT", - } { - if got := IsProtected(envVar); got { - t.Errorf("IsProtected(%q) = true, want false", envVar) - } - } - - // Checkout infra vars are agent-only: protected, never in the override scope. - for _, envVar := range []string{ - "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", "BUILDKITE_GIT_MIRRORS_PATH", "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", "BUILDKITE_SSH_KEYSCAN", "BUILDKITE_COMMAND_EVAL", - "MY_CUSTOM_VAR", } { + if got := IsProtected(envVar); !got { + t.Errorf("IsProtected(%q) = false, want true", envVar) + } if got := IsCheckoutOverrideScoped(envVar); got { t.Errorf("IsCheckoutOverrideScoped(%q) = true, want false", envVar) } } + + // An unrecognised var is neither protected nor checkout-scoped. + if IsProtected("MY_CUSTOM_VAR") { + t.Errorf("IsProtected(MY_CUSTOM_VAR) = true, want false") + } + if IsCheckoutOverrideScoped("MY_CUSTOM_VAR") { + t.Errorf("IsCheckoutOverrideScoped(MY_CUSTOM_VAR) = true, want false") + } +} + +func TestProtectedAndCheckoutScopeDisjoint(t *testing.T) { + t.Parallel() + + // A var must sit in exactly one tier. The enforcement sites read the two + // maps through different predicates, so a var in both would be locked + // inconsistently across hooks, secrets, the Job API, and config refresh. + for name := range checkoutOverrideScope { + if _, ok := protectedEnv[name]; ok { + t.Errorf("%q is in both protectedEnv and checkoutOverrideScope; it must sit in exactly one tier", name) + } + } } From dc0d80f858d269744f0d91cda498a8fa1ca75c9e Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 10 Jun 2026 15:00:25 -0400 Subject: [PATCH 17/57] Split no-checkout-override lock test by config type AgentStartConfig and BootstrapConfig use inverted command-eval conventions, so their zero values mean opposite things; per-type tables make that legible and drop the kind/switch discriminator. --- clicommand/agent_start_test.go | 86 ++++++++++++++-------------------- 1 file changed, 34 insertions(+), 52 deletions(-) diff --git a/clicommand/agent_start_test.go b/clicommand/agent_start_test.go index addad76f2b..e180880392 100644 --- a/clicommand/agent_start_test.go +++ b/clicommand/agent_start_test.go @@ -247,71 +247,53 @@ func TestAgentStartJobAcquisitionRejected_ExitCode27(t *testing.T) { } } -func TestNoCheckoutOverrideEnabled(t *testing.T) { +func TestAgentStartLockCheckoutWhenCommandEvalDisabled(t *testing.T) { t.Parallel() + // AgentStartConfig uses NoCommandEval, so the zero value leaves command-eval + // enabled and the lock off. tests := []struct { name string cfg AgentStartConfig - bCfg BootstrapConfig - kind string want bool }{ - { - name: "agent_start_explicit_flag", - cfg: AgentStartConfig{NoCheckoutOverride: true}, - kind: "agent", - want: true, - }, - { - name: "agent_start_no_command_eval_forces_lock", - cfg: AgentStartConfig{NoCommandEval: true}, - kind: "agent", - want: true, - }, - { - name: "agent_start_defaults_unlocked", - cfg: AgentStartConfig{}, - kind: "agent", - want: false, - }, - { - name: "bootstrap_explicit_flag", - bCfg: BootstrapConfig{NoCheckoutOverride: true, CommandEval: true}, - kind: "bootstrap", - want: true, - }, - { - name: "bootstrap_command_eval_disabled_forces_lock", - bCfg: BootstrapConfig{CommandEval: false}, - kind: "bootstrap", - want: true, - }, - { - name: "bootstrap_defaults_unlocked", - bCfg: BootstrapConfig{CommandEval: true}, - kind: "bootstrap", - want: false, - }, + {name: "explicit_flag", cfg: AgentStartConfig{NoCheckoutOverride: true}, want: true}, + {name: "no_command_eval_forces_lock", cfg: AgentStartConfig{NoCommandEval: true}, want: true}, + {name: "defaults_unlocked", cfg: AgentStartConfig{}, want: false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() + tc.cfg.lockCheckoutWhenCommandEvalDisabled() + if got := tc.cfg.NoCheckoutOverride; got != tc.want { + t.Errorf("NoCheckoutOverride = %v, want %v", got, tc.want) + } + }) + } +} - switch tc.kind { - case "agent": - tc.cfg.lockCheckoutWhenCommandEvalDisabled() - if got := tc.cfg.NoCheckoutOverride; got != tc.want { - t.Errorf("NoCheckoutOverride = %v, want %v", got, tc.want) - } - case "bootstrap": - tc.bCfg.lockCheckoutWhenCommandEvalDisabled() - if got := tc.bCfg.NoCheckoutOverride; got != tc.want { - t.Errorf("NoCheckoutOverride = %v, want %v", got, tc.want) - } - default: - t.Fatalf("unknown test kind %q", tc.kind) +func TestBootstrapLockCheckoutWhenCommandEvalDisabled(t *testing.T) { + t.Parallel() + + // BootstrapConfig uses CommandEval, so the zero value disables command-eval + // and forces the lock on; "unlocked" cases must set CommandEval explicitly. + tests := []struct { + name string + cfg BootstrapConfig + want bool + }{ + {name: "explicit_flag", cfg: BootstrapConfig{NoCheckoutOverride: true, CommandEval: true}, want: true}, + {name: "command_eval_disabled_forces_lock", cfg: BootstrapConfig{CommandEval: false}, want: true}, + {name: "defaults_unlocked", cfg: BootstrapConfig{CommandEval: true}, want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.cfg.lockCheckoutWhenCommandEvalDisabled() + if got := tc.cfg.NoCheckoutOverride; got != tc.want { + t.Errorf("NoCheckoutOverride = %v, want %v", got, tc.want) } }) } From 5d946208dc2afb9994e9c9c9f2171acf1bac8cc7 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 10 Jun 2026 15:00:26 -0400 Subject: [PATCH 18/57] Match no-checkout-override flag usage to no-* siblings --- clicommand/global.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clicommand/global.go b/clicommand/global.go index d93c2125d1..e8c1847bc8 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -207,7 +207,7 @@ var ( NoCheckoutOverrideFlag = cli.BoolFlag{ Name: "no-checkout-override", - Usage: "Prevent hooks, plugins, Job API, and secrets from overriding scoped checkout settings (default: false)", + Usage: "Don't allow hooks, plugins, Job API, and secrets to override scoped checkout settings (default: false)", EnvVar: "BUILDKITE_NO_CHECKOUT_OVERRIDE", } From 437842ca4852d3978a4f0d435f94d4b283ab4313 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Fri, 12 Jun 2026 16:55:42 -0400 Subject: [PATCH 19/57] Refine checkout-override env scope --- env/protected.go | 11 +++++++---- env/protected_test.go | 7 +++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/env/protected.go b/env/protected.go index 94092cf925..05c845261d 100644 --- a/env/protected.go +++ b/env/protected.go @@ -54,6 +54,7 @@ var protectedEnv = map[string]protection{ "BUILDKITE_GIT_COMMIT_VERIFICATION": {}, "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT": {}, "BUILDKITE_GIT_MIRRORS_PATH": {}, + "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE": {}, "BUILDKITE_HOOKS_PATH": {}, "BUILDKITE_HOOKS_SHELL": {}, "BUILDKITE_KUBERNETES_EXEC": {}, @@ -71,7 +72,8 @@ var protectedEnv = map[string]protection{ // checkoutOverrideScope contains checkout-related vars that remain mutable in // hooks, plugins, Job API, and secrets by default so jobs can tailor checkout // behavior. When checkout override is enabled, those same vars become locked so -// agent checkout config wins and git flags cannot be used to undermine +// agent checkout config wins: git is riddled with shell injections, so letting a +// job set git flags would otherwise be a way to bypass protections like // no-command-eval. Vars here must not also appear in protectedEnv; the two maps // are disjoint. var checkoutOverrideScope = map[string]struct{}{ @@ -81,8 +83,8 @@ var checkoutOverrideScope = map[string]struct{}{ "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": {}, "BUILDKITE_GIT_FETCH_FLAGS": {}, "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": {}, - "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE": {}, "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": {}, + "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS": {}, "BUILDKITE_GIT_SUBMODULES": {}, "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG": {}, "BUILDKITE_SKIP_CHECKOUT": {}, @@ -106,8 +108,9 @@ func IsProtectedFromWithinJob(name string) bool { return !prot.mutableFromWithinJob } -// IsCheckoutOverrideScoped reports whether name is a checkout-related env var -// that is write-locked when no-checkout-override is enabled. +// IsCheckoutOverrideScoped reports whether the environment variable is a +// checkout-related var that is write-protected (against job env, secrets, hooks, +// plugins, and the Job API) when no-checkout-override is enabled. func IsCheckoutOverrideScoped(name string) bool { _, exists := checkoutOverrideScope[normalizeKeyName(name)] return exists diff --git a/env/protected_test.go b/env/protected_test.go index 1877c608af..ffdc5d58f6 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -19,6 +19,7 @@ func TestProtectedEnv(t *testing.T) { "BUILDKITE_CONTAINER_COUNT", "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", "BUILDKITE_GIT_MIRRORS_PATH", + "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", "BUILDKITE_HOOKS_PATH", "BUILDKITE_KUBERNETES_EXEC", "BUILDKITE_LOCAL_HOOKS_ENABLED", @@ -79,9 +80,9 @@ func TestCheckoutOverrideScope(t *testing.T) { "BUILDKITE_GIT_FETCH_FLAGS", "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", - "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", "BUILDKITE_GIT_SUBMODULES", "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", + "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", "BUILDKITE_SKIP_CHECKOUT", } @@ -95,10 +96,12 @@ func TestCheckoutOverrideScope(t *testing.T) { } // Mirror infra is agent-only: always protected and never in the override - // scope, so a job can't relocate the mirror or stretch its lock timeout. + // scope, so a job can't relocate the mirror, stretch its lock timeout, or + // change the mirror checkout mode. for _, envVar := range []string{ "BUILDKITE_GIT_MIRRORS_PATH", "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", + "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", "BUILDKITE_SSH_KEYSCAN", "BUILDKITE_COMMAND_EVAL", } { From 7ec4129841ca3f08a61edd34a0b3d1696e0ab1ab Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Fri, 12 Jun 2026 16:55:42 -0400 Subject: [PATCH 20/57] Lock checkout vars in createEnvironment --- .../job_environment_integration_test.go | 89 ++++++++++++++++++- agent/job_runner.go | 45 ++++++---- 2 files changed, 115 insertions(+), 19 deletions(-) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index 094d8beecb..436d30dbfb 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -313,6 +313,83 @@ func TestCheckoutScopedJobEnvOverrideHonorsNoCheckoutOverride(t *testing.T) { wantEnvValue: "true", wantIgnoredEnvVars: []string{"BUILDKITE_SKIP_CHECKOUT"}, }, + { + name: "disabled_allows_job_env_to_override_sparse_checkout_paths", + varName: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS": "job/path", + }, + agentCfg: agent.AgentConfiguration{ + GitSparseCheckoutPaths: []string{"agent/path"}, + }, + wantEnvValue: "job/path", + }, + { + name: "enabled_locks_sparse_checkout_paths_to_agent_config", + varName: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS": "job/path", + }, + agentCfg: agent.AgentConfiguration{ + GitSparseCheckoutPaths: []string{"agent/path"}, + NoCheckoutOverride: true, + }, + wantEnvValue: "agent/path", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS"}, + }, + // Inverse cases: when the agent config sits on the side that emits no var + // by default, the lock must still force the agent value (regression for the + // leak where backend job env survived under no-checkout-override). + { + name: "enabled_locks_submodules_on_to_agent_config", + varName: "BUILDKITE_GIT_SUBMODULES", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SUBMODULES": "false", + }, + agentCfg: agent.AgentConfiguration{ + GitSubmodules: true, + NoCheckoutOverride: true, + }, + wantEnvValue: "true", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SUBMODULES"}, + }, + { + name: "disabled_allows_job_env_to_disable_submodules", + varName: "BUILDKITE_GIT_SUBMODULES", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SUBMODULES": "false", + }, + agentCfg: agent.AgentConfiguration{ + GitSubmodules: true, + }, + wantEnvValue: "false", + }, + { + name: "enabled_locks_skip_checkout_off_to_agent_config", + varName: "BUILDKITE_SKIP_CHECKOUT", + jobEnv: map[string]string{ + "BUILDKITE_SKIP_CHECKOUT": "true", + }, + agentCfg: agent.AgentConfiguration{ + SkipCheckout: false, + NoCheckoutOverride: true, + }, + wantEnvValue: "false", + wantIgnoredEnvVars: []string{"BUILDKITE_SKIP_CHECKOUT"}, + }, + { + name: "enabled_locks_skip_fetch_existing_commits_to_agent_config", + varName: "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": "true", + }, + agentCfg: agent.AgentConfiguration{ + GitSkipFetchExistingCommits: false, + NoCheckoutOverride: true, + }, + wantEnvValue: "false", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS"}, + }, } for _, tc := range tests { @@ -380,8 +457,9 @@ func TestCheckoutScopedJobEnvOverrideHonorsNoCheckoutOverride(t *testing.T) { func TestCheckoutInfraVarsAreAgentAuthoritative(t *testing.T) { t.Parallel() - // SSH_KEYSCAN, GIT_MIRRORS_PATH and GIT_MIRRORS_LOCK_TIMEOUT are agent-only: - // job env cannot override them even with no-checkout-override disabled. + // SSH_KEYSCAN, GIT_MIRRORS_PATH, GIT_MIRRORS_LOCK_TIMEOUT and + // GIT_MIRROR_CHECKOUT_MODE are agent-only: job env cannot override them even + // with no-checkout-override disabled. tests := []struct { name string varName string @@ -410,6 +488,13 @@ func TestCheckoutInfraVarsAreAgentAuthoritative(t *testing.T) { agentCfg: agent.AgentConfiguration{GitMirrorsLockTimeout: 300}, wantEnvValue: "300", }, + { + name: "git_mirror_checkout_mode", + varName: "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", + jobEnvValue: "id", + agentCfg: agent.AgentConfiguration{GitMirrorCheckoutMode: "raw"}, + wantEnvValue: "raw", + }, } for _, tc := range tests { diff --git a/agent/job_runner.go b/agent/job_runner.go index 7b333b032e..a4b0bd88e6 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -407,8 +407,9 @@ func (r *JobRunner) normalizeVerificationBehavior(behavior string) (string, erro func (r *JobRunner) createEnvironment(ctx context.Context) ([]string, error) { // Create a clone of our jobs environment. We'll then set the // environment variables provided by the agent, which will override any - // sent by Buildkite. The variables below should always take - // precedence. + // sent by Buildkite. The variables below should always take precedence, + // except the checkout-scoped vars set via setCheckoutEnv, which defer to + // the Buildkite-sent value unless no-checkout-override is enabled. env := make(map[string]string) maps.Copy(env, r.conf.Job.Env) @@ -605,20 +606,30 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setEnv("BUILDKITE_ADDITIONAL_HOOKS_PATHS", strings.Join(r.conf.AgentConfiguration.AdditionalHooksPaths, ",")) setEnv("BUILDKITE_PLUGINS_PATH", r.conf.AgentConfiguration.PluginsPath) setEnv("BUILDKITE_SSH_KEYSCAN", fmt.Sprint(r.conf.AgentConfiguration.SSHKeyscan)) - // Default submodules off when disabled in agent config, but let pipeline/ - // step env override via BUILDKITE_GIT_SUBMODULES unless no-checkout-override - // is set. - if !r.conf.AgentConfiguration.GitSubmodules { - setCheckoutEnv("BUILDKITE_GIT_SUBMODULES", "false") - } - // Allow BUILDKITE_SKIP_CHECKOUT to be enabled either by agent config - // or by pipeline/step env - // This is here now to make it ready for if/when we add skip_checkout to the core app - if r.conf.AgentConfiguration.SkipCheckout { - setCheckoutEnv("BUILDKITE_SKIP_CHECKOUT", "true") - } - if r.conf.AgentConfiguration.GitSkipFetchExistingCommits { - setCheckoutEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", "true") + // These three vars are only emitted by the agent on one side of their + // default (submodules off, skip-checkout on, skip-fetch on); on the other + // side the agent stays silent and lets pipeline/step env decide. That silent + // side would leak past no-checkout-override, so when the lock is on we emit + // the agent value unconditionally to keep agent config authoritative. + if r.conf.AgentConfiguration.NoCheckoutOverride { + setEnv("BUILDKITE_GIT_SUBMODULES", fmt.Sprint(r.conf.AgentConfiguration.GitSubmodules)) + setEnv("BUILDKITE_SKIP_CHECKOUT", fmt.Sprint(r.conf.AgentConfiguration.SkipCheckout)) + setEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", fmt.Sprint(r.conf.AgentConfiguration.GitSkipFetchExistingCommits)) + } else { + // Default submodules off when disabled in agent config, but let pipeline/ + // step env override via BUILDKITE_GIT_SUBMODULES. + if !r.conf.AgentConfiguration.GitSubmodules { + setCheckoutEnv("BUILDKITE_GIT_SUBMODULES", "false") + } + // Allow BUILDKITE_SKIP_CHECKOUT to be enabled either by agent config + // or by pipeline/step env + // This is here now to make it ready for if/when we add skip_checkout to the core app + if r.conf.AgentConfiguration.SkipCheckout { + setCheckoutEnv("BUILDKITE_SKIP_CHECKOUT", "true") + } + if r.conf.AgentConfiguration.GitSkipFetchExistingCommits { + setCheckoutEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", "true") + } } setEnv("BUILDKITE_NO_CHECKOUT_OVERRIDE", fmt.Sprint(r.conf.AgentConfiguration.NoCheckoutOverride)) setEnv("BUILDKITE_CHECKOUT_ATTEMPTS", strconv.Itoa(r.conf.AgentConfiguration.CheckoutAttempts)) @@ -634,7 +645,7 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setCheckoutEnv("BUILDKITE_GIT_CHECKOUT_FLAGS", r.conf.AgentConfiguration.GitCheckoutFlags) setCheckoutEnv("BUILDKITE_GIT_CLONE_FLAGS", r.conf.AgentConfiguration.GitCloneFlags) setCheckoutEnv("BUILDKITE_GIT_FETCH_FLAGS", r.conf.AgentConfiguration.GitFetchFlags) - setEnv("BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", strings.Join(r.conf.AgentConfiguration.GitSparseCheckoutPaths, ",")) + setCheckoutEnv("BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", strings.Join(r.conf.AgentConfiguration.GitSparseCheckoutPaths, ",")) setCheckoutEnv("BUILDKITE_GIT_CLONE_MIRROR_FLAGS", r.conf.AgentConfiguration.GitCloneMirrorFlags) setEnv("BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", r.conf.AgentConfiguration.GitMirrorCheckoutMode) setCheckoutEnv("BUILDKITE_GIT_CLEAN_FLAGS", r.conf.AgentConfiguration.GitCleanFlags) From ac89505f3d02fed1374b43dd6a72597853255eea Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Fri, 12 Jun 2026 16:55:42 -0400 Subject: [PATCH 21/57] Clarify no-checkout-override config docs --- clicommand/agent_start.go | 4 ++++ clicommand/bootstrap.go | 4 ++++ clicommand/global.go | 2 +- internal/job/config.go | 3 ++- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index d76b33efa6..c8e61e8d4c 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -230,6 +230,10 @@ type AgentStartConfig struct { DisconnectAfterJobTimeout int `cli:"disconnect-after-job-timeout" deprecated:"Use disconnect-after-idle-timeout instead"` } +// lockCheckoutWhenCommandEvalDisabled forces no-checkout-override on when +// command-eval is disabled, so git flags can't be used to bypass it. +// AgentStartConfig stores this as NoCommandEval; the BootstrapConfig sibling +// uses CommandEval, so its check is inverted. Keep the two in sync. func (cfg *AgentStartConfig) lockCheckoutWhenCommandEvalDisabled() { if cfg.NoCommandEval { cfg.NoCheckoutOverride = true diff --git a/clicommand/bootstrap.go b/clicommand/bootstrap.go index e04b458be7..9565f1989f 100644 --- a/clicommand/bootstrap.go +++ b/clicommand/bootstrap.go @@ -121,6 +121,10 @@ type BootstrapConfig struct { CheckoutAttempts int `cli:"checkout-attempts"` } +// lockCheckoutWhenCommandEvalDisabled forces no-checkout-override on when +// command-eval is disabled, so git flags can't be used to bypass it. +// BootstrapConfig stores this as CommandEval; the AgentStartConfig sibling uses +// NoCommandEval, so its check is inverted. Keep the two in sync. func (cfg *BootstrapConfig) lockCheckoutWhenCommandEvalDisabled() { if !cfg.CommandEval { cfg.NoCheckoutOverride = true diff --git a/clicommand/global.go b/clicommand/global.go index a1c6cc08e8..a13f8b706e 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -207,7 +207,7 @@ var ( NoCheckoutOverrideFlag = cli.BoolFlag{ Name: "no-checkout-override", - Usage: "Don't allow hooks, plugins, Job API, and secrets to override scoped checkout settings (default: false)", + Usage: "Don't allow pipeline/step env, hooks, plugins, the Job API, or secrets to override the agent's checkout settings (default: false)", EnvVar: "BUILDKITE_NO_CHECKOUT_OVERRIDE", } diff --git a/internal/job/config.go b/internal/job/config.go index 57b7db3423..209a24bdcb 100644 --- a/internal/job/config.go +++ b/internal/job/config.go @@ -86,7 +86,8 @@ type ExecutorConfig struct { // Skip git fetch if the commit already exists locally GitSkipFetchExistingCommits bool `env:"BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS"` - // This intentionally has no env tag so hooks cannot disable it at runtime. + // Lock the agent's checkout settings so the job cannot override them. + // Intentionally has no env tag so hooks cannot disable it at runtime. NoCheckoutOverride bool // Timeout in seconds for the git checkout phase (0 means no timeout) From 4f973303ef7c2bb220f71a932e55dc218b32a429 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Fri, 12 Jun 2026 16:55:42 -0400 Subject: [PATCH 22/57] Improve Job API checkout-lock rejection --- jobapi/env.go | 20 ++++++++++++++++++-- jobapi/server.go | 14 +++++++------- jobapi/server_test.go | 4 ++-- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/jobapi/env.go b/jobapi/env.go index c2cec7c91b..b6158c3044 100644 --- a/jobapi/env.go +++ b/jobapi/env.go @@ -41,7 +41,7 @@ func (s *Server) patchEnv(w http.ResponseWriter, r *http.Request) { if len(protected) > 0 { err := socket.WriteError( w, - fmt.Sprintf("the following environment variables are protected, and cannot be modified: % v", protected), + s.protectedEnvError(protected), http.StatusUnprocessableEntity, ) if err != nil { @@ -110,7 +110,7 @@ func (s *Server) deleteEnv(w http.ResponseWriter, r *http.Request) { if len(protected) > 0 { err := socket.WriteError( w, - fmt.Sprintf("the following environment variables are protected, and cannot be modified: % v", protected), + s.protectedEnvError(protected), http.StatusUnprocessableEntity, ) if err != nil { @@ -150,3 +150,19 @@ func (s *Server) checkProtected(candidates []string) []string { } return protected } + +// protectedEnvError builds the rejection message for protected candidates, +// noting when the rejection is due to the no-checkout-override lock rather than +// an always-protected var. +func (s *Server) protectedEnvError(protected []string) string { + msg := fmt.Sprintf("the following environment variables are protected, and cannot be modified: % v", protected) + if s.noCheckoutOverride { + for _, p := range protected { + if env.IsCheckoutOverrideScoped(p) { + msg += ". Checkout-related variables are locked because BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled" + break + } + } + } + return msg +} diff --git a/jobapi/server.go b/jobapi/server.go index 2adf039f99..535a85150f 100644 --- a/jobapi/server.go +++ b/jobapi/server.go @@ -32,15 +32,15 @@ func WithNoCheckoutOverride() ServerOpts { // and allows jobs to introspect and mutate their own state type Server struct { // SocketPath is the path to the socket that the server is (or will be) listening on - SocketPath string - Logger shell.Logger - debug bool - - mtx sync.RWMutex - environ *env.Environment - redactors *replacer.Mux + SocketPath string + Logger shell.Logger + debug bool noCheckoutOverride bool + mtx sync.RWMutex + environ *env.Environment + redactors *replacer.Mux + token string sockSvr *socket.Server } diff --git a/jobapi/server_test.go b/jobapi/server_test.go index 8612971e7d..60fd5c10f4 100644 --- a/jobapi/server_test.go +++ b/jobapi/server_test.go @@ -431,7 +431,7 @@ func TestPatchEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *testi testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvUpdateRequestPayload, jobapi.EnvUpdateResponse]{ expectedStatus: http.StatusUnprocessableEntity, expectedError: &jobapi.ErrorResponse{ - Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_SKIP_CHECKOUT]", + Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_SKIP_CHECKOUT]. Checkout-related variables are locked because BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled", }, expectedEnv: testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false").Dump(), }) @@ -469,7 +469,7 @@ func TestDeleteEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *test testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvDeleteRequest, jobapi.EnvDeleteResponse]{ expectedStatus: http.StatusUnprocessableEntity, expectedError: &jobapi.ErrorResponse{ - Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_SKIP_CHECKOUT]", + Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_SKIP_CHECKOUT]. Checkout-related variables are locked because BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled", }, expectedEnv: testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false").Dump(), }) From 14a9313e6b5508d74176e6c4314707e42819bb92 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Fri, 12 Jun 2026 16:55:42 -0400 Subject: [PATCH 23/57] Test no-command-eval auto-locks checkout --- .../job/integration/hooks_integration_test.go | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/internal/job/integration/hooks_integration_test.go b/internal/job/integration/hooks_integration_test.go index 90c59ced0c..65ccf4b05c 100644 --- a/internal/job/integration/hooks_integration_test.go +++ b/internal/job/integration/hooks_integration_test.go @@ -242,6 +242,53 @@ func TestEnvironmentHookCannotDisableNoCheckoutOverride(t *testing.T) { } } +func TestNoCommandEvalAutoLocksCheckoutVars(t *testing.T) { + t.Parallel() + + // no-command-eval implies no-checkout-override: with command eval disabled + // the agent must lock checkout vars so git flags cannot be used to bypass + // the no-command-eval protection. The job never sets + // BUILDKITE_NO_CHECKOUT_OVERRIDE; the lock is derived purely from + // command-eval being off. + tester, err := NewExecutorTester(mainCtx) + if err != nil { + t.Fatalf("NewExecutorTester() error = %v", err) + } + defer tester.Close() + + filename := "environment" + script := []string{ + "#!/usr/bin/env bash", + "export BUILDKITE_SKIP_CHECKOUT=true", + } + if runtime.GOOS == "windows" { + filename = "environment.bat" + script = []string{ + "@echo off", + "set BUILDKITE_SKIP_CHECKOUT=true", + } + } + + if err := os.WriteFile(filepath.Join(tester.HooksDir, filename), []byte(strings.Join(script, "\n")), 0o700); err != nil { + t.Fatalf("os.WriteFile(%q, script, 0o700) = %v", filename, err) + } + + tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + if got := c.GetEnv("BUILDKITE_SKIP_CHECKOUT"); got == "true" { + _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want the no-command-eval lock to block it\n", got) + c.Exit(1) + return + } + c.Exit(0) + }) + + tester.RunAndCheck(t, "BUILDKITE_COMMAND_EVAL=false") + + if !strings.Contains(tester.Output, "env vars were blocked") || !strings.Contains(tester.Output, "BUILDKITE_SKIP_CHECKOUT") { + t.Fatalf("expected BUILDKITE_SKIP_CHECKOUT to be reported as blocked\noutput: %s", tester.Output) + } +} + func TestDirectoryPassesBetweenHooks(t *testing.T) { t.Parallel() From 7efc6c4232251f6d8323002cf867897d6536bc41 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 24 Jun 2026 16:02:50 -0400 Subject: [PATCH 24/57] Update agent/job_runner.go Co-authored-by: Josh Deprez --- agent/job_runner.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/agent/job_runner.go b/agent/job_runner.go index a4b0bd88e6..71921a169a 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -441,6 +441,9 @@ func (r *JobRunner) createEnvironment(ctx context.Context) ([]string, error) { } env[name] = value } + // For some checkout env vars, we allow the Job env (if present) to + // have higher precedence than the agent configuration, unless + // NoCheckoutOverride is set. setCheckoutEnv := func(name, value string) { if !r.conf.AgentConfiguration.NoCheckoutOverride { if _, exists := env[name]; exists { From 2ca7ebede4831c09f435bef5d53da4ce71682bbd Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 24 Jun 2026 16:21:05 -0400 Subject: [PATCH 25/57] Make git-submodule-clone-config agent-authoritative It becomes 'git -c ' for submodule clones (an injection vector) with no backend customization, so move it from checkoutOverrideScope to the always-protected tier. --- agent/job_runner.go | 2 +- env/protected.go | 6 +++++- env/protected_test.go | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/agent/job_runner.go b/agent/job_runner.go index 71921a169a..5afac23199 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -656,7 +656,7 @@ BUILDKITE_AGENT_JWKS_KEY_ID` if r.conf.AgentConfiguration.GitCheckoutTimeout > 0 { setEnv("BUILDKITE_GIT_CHECKOUT_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitCheckoutTimeout)) } - setCheckoutEnv("BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", strings.Join(r.conf.AgentConfiguration.GitSubmoduleCloneConfig, ",")) + setEnv("BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", strings.Join(r.conf.AgentConfiguration.GitSubmoduleCloneConfig, ",")) setEnv("BUILDKITE_GIT_COMMIT_VERIFICATION", r.conf.AgentConfiguration.GitCommitVerification) setEnv("BUILDKITE_SHELL", r.conf.AgentConfiguration.Shell) diff --git a/env/protected.go b/env/protected.go index 05c845261d..f32e935787 100644 --- a/env/protected.go +++ b/env/protected.go @@ -22,6 +22,10 @@ type protection struct { // disables plugins, but even if it is changed by a hook, the agent doesn't // reconfigure no-command-eval based on any changes.) // +// BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG is applied as `git -c ` before +// submodule clones (an injection vector) and has no backend customization, so +// it stays agent-authoritative rather than in checkoutOverrideScope. +// // The actual enforcement of protected env within the agent level (overriding // job-level env vars based on agent configuration) happens implicitly rather // than relying on this map - see createEnvironment in agent/job_runner.go. @@ -55,6 +59,7 @@ var protectedEnv = map[string]protection{ "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT": {}, "BUILDKITE_GIT_MIRRORS_PATH": {}, "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE": {}, + "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG": {}, "BUILDKITE_HOOKS_PATH": {}, "BUILDKITE_HOOKS_SHELL": {}, "BUILDKITE_KUBERNETES_EXEC": {}, @@ -86,7 +91,6 @@ var checkoutOverrideScope = map[string]struct{}{ "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": {}, "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS": {}, "BUILDKITE_GIT_SUBMODULES": {}, - "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG": {}, "BUILDKITE_SKIP_CHECKOUT": {}, } diff --git a/env/protected_test.go b/env/protected_test.go index ffdc5d58f6..a99b86588c 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -20,6 +20,7 @@ func TestProtectedEnv(t *testing.T) { "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", "BUILDKITE_GIT_MIRRORS_PATH", "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", + "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", "BUILDKITE_HOOKS_PATH", "BUILDKITE_KUBERNETES_EXEC", "BUILDKITE_LOCAL_HOOKS_ENABLED", @@ -78,7 +79,6 @@ func TestCheckoutOverrideScope(t *testing.T) { "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", "BUILDKITE_GIT_CLEAN_FLAGS", "BUILDKITE_GIT_FETCH_FLAGS", - "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", "BUILDKITE_GIT_SUBMODULES", "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", @@ -97,11 +97,13 @@ func TestCheckoutOverrideScope(t *testing.T) { // Mirror infra is agent-only: always protected and never in the override // scope, so a job can't relocate the mirror, stretch its lock timeout, or - // change the mirror checkout mode. + // change the mirror checkout mode. Submodule clone config is likewise + // always protected (a `git -c` injection vector with no backend knob). for _, envVar := range []string{ "BUILDKITE_GIT_MIRRORS_PATH", "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", + "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", "BUILDKITE_SSH_KEYSCAN", "BUILDKITE_COMMAND_EVAL", } { From a0d2e4ee8bb500cf9da2a7a51b678a5f55294c93 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Thu, 25 Jun 2026 10:09:59 -0400 Subject: [PATCH 26/57] test: cover sparse-checkout-paths under no-checkout-override --- .../job/integration/hooks_integration_test.go | 39 ++++++++++++------ .../integration/plugin_integration_test.go | 30 ++++++++------ .../integration/secrets_integration_test.go | 19 +++++---- jobapi/server_test.go | 40 +++++++++++++++++++ 4 files changed, 95 insertions(+), 33 deletions(-) diff --git a/internal/job/integration/hooks_integration_test.go b/internal/job/integration/hooks_integration_test.go index 6fb755997c..a8339651ff 100644 --- a/internal/job/integration/hooks_integration_test.go +++ b/internal/job/integration/hooks_integration_test.go @@ -124,17 +124,32 @@ func TestEnvironmentHookNoCheckoutOverride(t *testing.T) { t.Parallel() tests := []struct { - name string - noCheckoutOverride bool - wantSkipCheckoutEnv string - wantBlockedWarning bool + name string + envVar string + envValue string + noCheckoutOverride bool + wantEnv string + wantBlockedWarning bool }{ { - name: "disabled_allows_mutation", - wantSkipCheckoutEnv: "true", + name: "disabled_allows_skip_checkout", + envVar: "BUILDKITE_SKIP_CHECKOUT", + envValue: "true", + wantEnv: "true", }, { - name: "enabled_blocks_mutation", + name: "enabled_blocks_skip_checkout", + envVar: "BUILDKITE_SKIP_CHECKOUT", + envValue: "true", + noCheckoutOverride: true, + wantBlockedWarning: true, + }, + { + // Sparse paths is only exercised in the blocked direction: the lock + // strips it before checkout, so the real checkout is unaffected. + name: "enabled_blocks_sparse_checkout_paths", + envVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", + envValue: "a/b", noCheckoutOverride: true, wantBlockedWarning: true, }, @@ -153,13 +168,13 @@ func TestEnvironmentHookNoCheckoutOverride(t *testing.T) { filename := "environment" script := []string{ "#!/usr/bin/env bash", - "export BUILDKITE_SKIP_CHECKOUT=true", + fmt.Sprintf("export %s=%s", tc.envVar, tc.envValue), } if runtime.GOOS == "windows" { filename = "environment.bat" script = []string{ "@echo off", - "set BUILDKITE_SKIP_CHECKOUT=true", + fmt.Sprintf("set %s=%s", tc.envVar, tc.envValue), } } @@ -168,8 +183,8 @@ func TestEnvironmentHookNoCheckoutOverride(t *testing.T) { } tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { - if got, want := c.GetEnv("BUILDKITE_SKIP_CHECKOUT"), tc.wantSkipCheckoutEnv; got != want { - _, _ = fmt.Fprintf(c.Stderr, "Expected BUILDKITE_SKIP_CHECKOUT=%q, got %q\n", want, got) + if got, want := c.GetEnv(tc.envVar), tc.wantEnv; got != want { + _, _ = fmt.Fprintf(c.Stderr, "Expected %s=%q, got %q\n", tc.envVar, want, got) c.Exit(1) return } @@ -184,7 +199,7 @@ func TestEnvironmentHookNoCheckoutOverride(t *testing.T) { tester.RunAndCheck(t, env...) containsWarning := strings.Contains(tester.Output, "env vars were blocked") && - strings.Contains(tester.Output, "BUILDKITE_SKIP_CHECKOUT") + strings.Contains(tester.Output, tc.envVar) if containsWarning != tc.wantBlockedWarning { t.Fatalf("blocked warning presence = %t, want %t\noutput: %s", containsWarning, tc.wantBlockedWarning, tester.Output) } diff --git a/internal/job/integration/plugin_integration_test.go b/internal/job/integration/plugin_integration_test.go index 2eeb3a364c..c587422973 100644 --- a/internal/job/integration/plugin_integration_test.go +++ b/internal/job/integration/plugin_integration_test.go @@ -85,11 +85,14 @@ func TestPluginEnvironmentHookNoCheckoutOverride(t *testing.T) { // default, but no-checkout-override must block that mutation. tests := []struct { name string + envVar string + envValue string noCheckoutOverride bool wantBlocked bool }{ - {name: "disabled_allows_plugin_to_override_clone_flags"}, - {name: "enabled_blocks_plugin_clone_flags_override", noCheckoutOverride: true, wantBlocked: true}, + {name: "disabled_allows_plugin_to_override_skip_checkout", envVar: "BUILDKITE_SKIP_CHECKOUT", envValue: "true"}, + {name: "enabled_blocks_plugin_skip_checkout_override", envVar: "BUILDKITE_SKIP_CHECKOUT", envValue: "true", noCheckoutOverride: true, wantBlocked: true}, + {name: "enabled_blocks_plugin_sparse_checkout_paths_override", envVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", envValue: "a/b", noCheckoutOverride: true, wantBlocked: true}, } for _, tc := range tests { @@ -102,20 +105,21 @@ func TestPluginEnvironmentHookNoCheckoutOverride(t *testing.T) { } defer tester.Close() - // SKIP_CHECKOUT is a benign scoped var to assert on: setting it true - // just skips cloning, so the build still succeeds either way. Setting - // a real clone flag would break the checkout when the override lands. + // These scoped vars keep the checkout safe: SKIP_CHECKOUT just skips + // cloning, and sparse paths is only exercised in the blocked direction + // where the lock strips it before checkout. Setting a real clone flag + // in the allowed direction would break the checkout. hooks := map[string][]string{ "environment": { "#!/usr/bin/env bash", - "export BUILDKITE_SKIP_CHECKOUT=true", + fmt.Sprintf("export %s=%s", tc.envVar, tc.envValue), }, } if runtime.GOOS == "windows" { hooks = map[string][]string{ "environment.bat": { "@echo off", - "set BUILDKITE_SKIP_CHECKOUT=true", + fmt.Sprintf("set %s=%s", tc.envVar, tc.envValue), }, } } @@ -127,14 +131,14 @@ func TestPluginEnvironmentHookNoCheckoutOverride(t *testing.T) { } tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { - got := c.GetEnv("BUILDKITE_SKIP_CHECKOUT") - if tc.wantBlocked && got == "true" { - _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want the plugin override to be blocked\n", got) + got := c.GetEnv(tc.envVar) + if tc.wantBlocked && got == tc.envValue { + _, _ = fmt.Fprintf(c.Stderr, "%s=%q, want the plugin override to be blocked\n", tc.envVar, got) c.Exit(1) return } - if !tc.wantBlocked && got != "true" { - _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want %q\n", got, "true") + if !tc.wantBlocked && got != tc.envValue { + _, _ = fmt.Fprintf(c.Stderr, "%s=%q, want %q\n", tc.envVar, got, tc.envValue) c.Exit(1) return } @@ -149,7 +153,7 @@ func TestPluginEnvironmentHookNoCheckoutOverride(t *testing.T) { tester.RunAndCheck(t, env...) containsWarning := strings.Contains(tester.Output, "env vars were blocked") && - strings.Contains(tester.Output, "BUILDKITE_SKIP_CHECKOUT") + strings.Contains(tester.Output, tc.envVar) if containsWarning != tc.wantBlocked { t.Fatalf("blocked warning presence = %t, want %t\noutput: %s", containsWarning, tc.wantBlocked, tester.Output) } diff --git a/internal/job/integration/secrets_integration_test.go b/internal/job/integration/secrets_integration_test.go index 8599df8a7d..ba8cffd8fe 100644 --- a/internal/job/integration/secrets_integration_test.go +++ b/internal/job/integration/secrets_integration_test.go @@ -569,11 +569,14 @@ func TestSecretsIntegration_NoCheckoutOverride(t *testing.T) { tests := []struct { name string + envVar string + secretValue string noCheckoutOverride bool wantErr bool }{ - {name: "disabled_allows_checkout_scoped_secret"}, - {name: "enabled_rejects_checkout_locked_secret", noCheckoutOverride: true, wantErr: true}, + {name: "disabled_allows_checkout_scoped_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror"}, + {name: "enabled_rejects_checkout_locked_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", noCheckoutOverride: true, wantErr: true}, + {name: "enabled_rejects_sparse_checkout_paths_secret", envVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", secretValue: "a/b", noCheckoutOverride: true, wantErr: true}, } for _, tc := range tests { @@ -586,12 +589,12 @@ func TestSecretsIntegration_NoCheckoutOverride(t *testing.T) { } defer tester.Close() - apiServer := setupSecretsAPIServer(t, map[string]string{"CLONE_FLAGS_SECRET": "--mirror"}) + apiServer := setupSecretsAPIServer(t, map[string]string{"CHECKOUT_SECRET": tc.secretValue}) defer apiServer.Close() secretsJSON, err := json.Marshal([]pipeline.Secret{{ - Key: "CLONE_FLAGS_SECRET", - EnvironmentVariable: "BUILDKITE_GIT_CLONE_FLAGS", + Key: "CHECKOUT_SECRET", + EnvironmentVariable: tc.envVar, }}) if err != nil { t.Fatalf("marshaling secrets: %v", err) @@ -599,8 +602,8 @@ func TestSecretsIntegration_NoCheckoutOverride(t *testing.T) { if !tc.wantErr { tester.ExpectGlobalHook("command").AndCallFunc(func(c *bintest.Call) { - if got, want := c.GetEnv("BUILDKITE_GIT_CLONE_FLAGS"), "--mirror"; got != want { - _, _ = fmt.Fprintf(c.Stderr, "Expected BUILDKITE_GIT_CLONE_FLAGS=%q, got %q\n", want, got) + if got, want := c.GetEnv(tc.envVar), tc.secretValue; got != want { + _, _ = fmt.Fprintf(c.Stderr, "Expected %s=%q, got %q\n", tc.envVar, want, got) c.Exit(1) return } @@ -621,7 +624,7 @@ func TestSecretsIntegration_NoCheckoutOverride(t *testing.T) { if err == nil { t.Fatalf("expected job to fail due to checkout-locked secret mapping, but it succeeded. Full output: %s", tester.Output) } - if !strings.Contains(tester.Output, "checkout-locked environment variable") || !strings.Contains(tester.Output, "BUILDKITE_GIT_CLONE_FLAGS") { + if !strings.Contains(tester.Output, "checkout-locked environment variable") || !strings.Contains(tester.Output, tc.envVar) { t.Fatalf("expected output to mention checkout-locked env rejection, got: %s", tester.Output) } return diff --git a/jobapi/server_test.go b/jobapi/server_test.go index 60fd5c10f4..b056d6323c 100644 --- a/jobapi/server_test.go +++ b/jobapi/server_test.go @@ -475,6 +475,46 @@ func TestDeleteEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *test }) } +func TestPatchEnvRejectsSparseCheckoutPathsWhenNoCheckoutOverrideEnabled(t *testing.T) { + t.Parallel() + + environ := testEnvironWith("BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", "a/b") + srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride()) + if err != nil { + t.Fatalf("creating server: %v", err) + } + + if err := srv.Start(); err != nil { + t.Fatalf("starting server: %v", err) + } + defer func() { + if err := srv.Stop(); err != nil { + t.Fatalf("stopping server: %v", err) + } + }() + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(&jobapi.EnvUpdateRequestPayload{ + Env: map[string]*string{"BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS": pt("c/d")}, + }); err != nil { + t.Fatalf("encoding request body: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, "http://job/api/current-job/v0/env", buf) + if err != nil { + t.Fatalf("creating request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvUpdateRequestPayload, jobapi.EnvUpdateResponse]{ + expectedStatus: http.StatusUnprocessableEntity, + expectedError: &jobapi.ErrorResponse{ + Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS]. Checkout-related variables are locked because BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled", + }, + expectedEnv: testEnvironWith("BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", "a/b").Dump(), + }) +} + func TestGetEnv(t *testing.T) { t.Parallel() From 8b9f7bea05aededc03904ab6b233082556cc5f1f Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Thu, 25 Jun 2026 10:10:52 -0400 Subject: [PATCH 27/57] test: assert no-checkout-override does not over-block unscoped vars --- .../integration/secrets_integration_test.go | 2 + jobapi/server_test.go | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/internal/job/integration/secrets_integration_test.go b/internal/job/integration/secrets_integration_test.go index ba8cffd8fe..6d8f54b5b0 100644 --- a/internal/job/integration/secrets_integration_test.go +++ b/internal/job/integration/secrets_integration_test.go @@ -577,6 +577,8 @@ func TestSecretsIntegration_NoCheckoutOverride(t *testing.T) { {name: "disabled_allows_checkout_scoped_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror"}, {name: "enabled_rejects_checkout_locked_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", noCheckoutOverride: true, wantErr: true}, {name: "enabled_rejects_sparse_checkout_paths_secret", envVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", secretValue: "a/b", noCheckoutOverride: true, wantErr: true}, + // The lock must not over-block: a non-checkout secret is still allowed. + {name: "enabled_allows_unscoped_secret", envVar: "MY_CUSTOM_VAR", secretValue: "hello", noCheckoutOverride: true}, } for _, tc := range tests { diff --git a/jobapi/server_test.go b/jobapi/server_test.go index b056d6323c..077f3297ae 100644 --- a/jobapi/server_test.go +++ b/jobapi/server_test.go @@ -515,6 +515,45 @@ func TestPatchEnvRejectsSparseCheckoutPathsWhenNoCheckoutOverrideEnabled(t *test }) } +func TestPatchEnvAllowsUnscopedVarsWhenNoCheckoutOverrideEnabled(t *testing.T) { + t.Parallel() + + // The lock must not over-block: a normal, non-checkout var stays writable + // while BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled. + environ := testEnviron() + srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride()) + if err != nil { + t.Fatalf("creating server: %v", err) + } + + if err := srv.Start(); err != nil { + t.Fatalf("starting server: %v", err) + } + defer func() { + if err := srv.Stop(); err != nil { + t.Fatalf("stopping server: %v", err) + } + }() + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(&jobapi.EnvUpdateRequestPayload{ + Env: map[string]*string{"MY_CUSTOM_VAR": pt("hello")}, + }); err != nil { + t.Fatalf("encoding request body: %v", err) + } + + req, err := http.NewRequest(http.MethodPatch, "http://job/api/current-job/v0/env", buf) + if err != nil { + t.Fatalf("creating request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvUpdateRequestPayload, jobapi.EnvUpdateResponse]{ + expectedStatus: http.StatusOK, + expectedEnv: testEnvironWith("MY_CUSTOM_VAR", "hello").Dump(), + }) +} + func TestGetEnv(t *testing.T) { t.Parallel() From b1e9ded3e1341c17a260f766db352c3ac85befe1 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Thu, 25 Jun 2026 15:02:03 -0400 Subject: [PATCH 28/57] Ungroup jobapi type declarations The grouped type block was incidental churn from the no-checkout-override change and ran against the package convention of standalone declarations. --- jobapi/server.go | 53 ++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/jobapi/server.go b/jobapi/server.go index c8160965b3..d6c34f6153 100644 --- a/jobapi/server.go +++ b/jobapi/server.go @@ -42,33 +42,32 @@ func WithPromiseFailureDeclarer(d PromiseFailureDeclarer) ServerOpts { // Buildkite API. It returns the status code of the most recent API response (0 // if none was received, e.g. a network error after exhausting retries) and an // error describing any failure. A nil error means the declaration was accepted. -type ( - PromiseFailureDeclarer func(ctx context.Context, exitStatus int, reason string) (statusCode int, err error) - // Server is a Job API server. It provides an HTTP API with which to interact with the job currently running in the buildkite agent - // and allows jobs to introspect and mutate their own state - Server struct { - // SocketPath is the path to the socket that the server is (or will be) listening on - SocketPath string - Logger shell.Logger - debug bool - noCheckoutOverride bool - - mtx sync.RWMutex - environ *env.Environment - redactors *replacer.Mux - - // pendingWorkdir holds an absolute working directory requested by a hook via - // the /workdir endpoint, waiting to be applied by the executor once the hook - // process exits. Guarded by mtx. - pendingWorkdir string - - // promiseFailures coalesces concurrent and repeated promise-failure calls. - promiseFailures *promiseFailureCoordinator - - token string - sockSvr *socket.Server - } -) +type PromiseFailureDeclarer func(ctx context.Context, exitStatus int, reason string) (statusCode int, err error) + +// Server is a Job API server. It provides an HTTP API with which to interact with the job currently running in the buildkite agent +// and allows jobs to introspect and mutate their own state +type Server struct { + // SocketPath is the path to the socket that the server is (or will be) listening on + SocketPath string + Logger shell.Logger + debug bool + noCheckoutOverride bool + + mtx sync.RWMutex + environ *env.Environment + redactors *replacer.Mux + + // pendingWorkdir holds an absolute working directory requested by a hook via + // the /workdir endpoint, waiting to be applied by the executor once the hook + // process exits. Guarded by mtx. + pendingWorkdir string + + // promiseFailures coalesces concurrent and repeated promise-failure calls. + promiseFailures *promiseFailureCoordinator + + token string + sockSvr *socket.Server +} // NewServer creates a new Job API server // socketPath is the path to the socket on which the server will listen From ba49b5a7edaf39267fc1a9bba6330748321f3f01 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Thu, 25 Jun 2026 15:02:10 -0400 Subject: [PATCH 29/57] test: cover remaining checkout-scoped vars under no-checkout-override --- .../job_environment_integration_test.go | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index 436d30dbfb..4616b3036f 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -390,6 +390,129 @@ func TestCheckoutScopedJobEnvOverrideHonorsNoCheckoutOverride(t *testing.T) { wantEnvValue: "false", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS"}, }, + // The remaining checkout-scoped vars all flow through setCheckoutEnv; cover + // each in both directions so a regression in any one is caught. The git flag + // vars are the injection vectors the lock exists to contain. + { + name: "disabled_allows_job_env_to_override_checkout_flags", + varName: "BUILDKITE_GIT_CHECKOUT_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CHECKOUT_FLAGS": "--quiet", + }, + agentCfg: agent.AgentConfiguration{ + GitCheckoutFlags: "-f", + }, + wantEnvValue: "--quiet", + }, + { + name: "enabled_locks_checkout_flags_to_agent_config", + varName: "BUILDKITE_GIT_CHECKOUT_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CHECKOUT_FLAGS": "--quiet", + }, + agentCfg: agent.AgentConfiguration{ + GitCheckoutFlags: "-f", + NoCheckoutOverride: true, + }, + wantEnvValue: "-f", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CHECKOUT_FLAGS"}, + }, + { + name: "disabled_allows_job_env_to_override_fetch_flags", + varName: "BUILDKITE_GIT_FETCH_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_FETCH_FLAGS": "--prune", + }, + agentCfg: agent.AgentConfiguration{ + GitFetchFlags: "-v", + }, + wantEnvValue: "--prune", + }, + { + name: "enabled_locks_fetch_flags_to_agent_config", + varName: "BUILDKITE_GIT_FETCH_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_FETCH_FLAGS": "--prune", + }, + agentCfg: agent.AgentConfiguration{ + GitFetchFlags: "-v", + NoCheckoutOverride: true, + }, + wantEnvValue: "-v", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_FETCH_FLAGS"}, + }, + { + name: "disabled_allows_job_env_to_override_clean_flags", + varName: "BUILDKITE_GIT_CLEAN_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CLEAN_FLAGS": "-fdq", + }, + agentCfg: agent.AgentConfiguration{ + GitCleanFlags: "-ffxdq", + }, + wantEnvValue: "-fdq", + }, + { + name: "enabled_locks_clean_flags_to_agent_config", + varName: "BUILDKITE_GIT_CLEAN_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CLEAN_FLAGS": "-fdq", + }, + agentCfg: agent.AgentConfiguration{ + GitCleanFlags: "-ffxdq", + NoCheckoutOverride: true, + }, + wantEnvValue: "-ffxdq", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLEAN_FLAGS"}, + }, + { + name: "disabled_allows_job_env_to_override_clone_mirror_flags", + varName: "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": "--mirror", + }, + agentCfg: agent.AgentConfiguration{ + GitCloneMirrorFlags: "--bare", + }, + wantEnvValue: "--mirror", + }, + { + name: "enabled_locks_clone_mirror_flags_to_agent_config", + varName: "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": "--mirror", + }, + agentCfg: agent.AgentConfiguration{ + GitCloneMirrorFlags: "--bare", + NoCheckoutOverride: true, + }, + wantEnvValue: "--bare", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_MIRROR_FLAGS"}, + }, + { + name: "disabled_allows_job_env_to_override_mirrors_skip_update", + varName: "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", + jobEnv: map[string]string{ + "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": "true", + }, + agentCfg: agent.AgentConfiguration{ + GitMirrorsSkipUpdate: false, + }, + wantEnvValue: "true", + }, + { + name: "enabled_locks_mirrors_skip_update_to_agent_config", + varName: "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", + jobEnv: map[string]string{ + "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": "true", + }, + agentCfg: agent.AgentConfiguration{ + GitMirrorsSkipUpdate: false, + NoCheckoutOverride: true, + }, + wantEnvValue: "false", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_MIRRORS_SKIP_UPDATE"}, + }, } for _, tc := range tests { From d392e11c6d54f316ab01381d64e21f13274d610e Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 13:45:00 -0400 Subject: [PATCH 30/57] Add tri-state checkout override mode to env --- env/protected.go | 102 ++++++++++++++++++++++++++++++++++++++++-- env/protected_test.go | 93 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 188 insertions(+), 7 deletions(-) diff --git a/env/protected.go b/env/protected.go index f32e935787..8a47d679b1 100644 --- a/env/protected.go +++ b/env/protected.go @@ -1,5 +1,7 @@ package env +import "fmt" + type protection struct { // Some otherwise-protected env vars may be written from within the job // being executed, including hooks and plugins. @@ -52,6 +54,7 @@ var protectedEnv = map[string]protection{ "BUILDKITE_ARTIFACT_UPLOAD_DESTINATION": {mutableFromWithinJob: true}, "BUILDKITE_BIN_PATH": {}, "BUILDKITE_BUILD_PATH": {}, + "BUILDKITE_CHECKOUT_OVERRIDE_MODE": {}, "BUILDKITE_COMMAND_EVAL": {}, "BUILDKITE_CONFIG_PATH": {}, "BUILDKITE_CONTAINER_COUNT": {}, @@ -64,7 +67,6 @@ var protectedEnv = map[string]protection{ "BUILDKITE_HOOKS_SHELL": {}, "BUILDKITE_KUBERNETES_EXEC": {}, "BUILDKITE_LOCAL_HOOKS_ENABLED": {}, - "BUILDKITE_NO_CHECKOUT_OVERRIDE": {}, "BUILDKITE_PLUGINS_ALWAYS_CLONE_FRESH": {mutableFromWithinJob: true}, "BUILDKITE_PLUGINS_ENABLED": {}, "BUILDKITE_PLUGINS_PATH": {}, @@ -113,9 +115,103 @@ func IsProtectedFromWithinJob(name string) bool { } // IsCheckoutOverrideScoped reports whether the environment variable is a -// checkout-related var that is write-protected (against job env, secrets, hooks, -// plugins, and the Job API) when no-checkout-override is enabled. +// checkout-related var whose write-protection depends on the checkout-override +// mode. Whether it's actually locked for a given source is decided by +// IsCheckoutLocked and IsCheckoutLockedFromWithinJob. func IsCheckoutOverrideScoped(name string) bool { _, exists := checkoutOverrideScope[normalizeKeyName(name)] return exists } + +// CheckoutOverrideMode controls how much of the agent's checkout configuration a +// job may override. It applies only to checkoutOverrideScope vars; protectedEnv +// membership is independent of the mode. +type CheckoutOverrideMode int + +const ( + // CheckoutOverrideFromJob is the default and matches the agent's historical + // behaviour: checkout-scoped vars are locked against backend job env and + // secrets, but hooks, plugins, and the Job API may still set them so a job + // can tailor its own checkout. + CheckoutOverrideFromJob CheckoutOverrideMode = iota + + // CheckoutOverrideStrict makes agent checkout config authoritative against + // every source: backend job env, secrets, hooks, plugins, and the Job API. + CheckoutOverrideStrict + + // CheckoutOverrideNone lets any source override agent checkout config. + CheckoutOverrideNone +) + +// Accepted flag/env values for the checkout-override modes. +const ( + checkoutOverrideFromJobName = "from-job" + checkoutOverrideStrictName = "strict" + checkoutOverrideNoneName = "none" +) + +// CheckoutOverrideModeNames lists the accepted flag/env values, strictest first. +var CheckoutOverrideModeNames = []string{ + checkoutOverrideStrictName, + checkoutOverrideFromJobName, + checkoutOverrideNoneName, +} + +func (m CheckoutOverrideMode) String() string { + switch m { + case CheckoutOverrideStrict: + return checkoutOverrideStrictName + case CheckoutOverrideNone: + return checkoutOverrideNoneName + default: + return checkoutOverrideFromJobName + } +} + +// ParseCheckoutOverrideMode maps a flag/env value to a mode. An empty string +// selects the default (from-job). +func ParseCheckoutOverrideMode(s string) (CheckoutOverrideMode, error) { + switch s { + case "", checkoutOverrideFromJobName: + return CheckoutOverrideFromJob, nil + case checkoutOverrideStrictName: + return CheckoutOverrideStrict, nil + case checkoutOverrideNoneName: + return CheckoutOverrideNone, nil + default: + return CheckoutOverrideFromJob, fmt.Errorf("invalid checkout-override mode %q, must be one of %v", s, CheckoutOverrideModeNames) + } +} + +// FlooredForCommandEval restricts the mode so command-eval can't be bypassed: +// when command-eval is disabled, CheckoutOverrideNone is raised to +// CheckoutOverrideFromJob, which still blocks backend job env and secret git +// flags (the injection vector) while leaving hooks and plugins free to tailor +// checkout. +func (m CheckoutOverrideMode) FlooredForCommandEval(commandEvalEnabled bool) CheckoutOverrideMode { + if !commandEvalEnabled && m == CheckoutOverrideNone { + return CheckoutOverrideFromJob + } + return m +} + +// IsCheckoutLocked reports whether a checkout-scoped var is locked against writes +// from outside the running job (backend job env and secrets) under the given +// mode. Vars that aren't checkout-scoped are governed by IsProtected instead. +func IsCheckoutLocked(name string, mode CheckoutOverrideMode) bool { + if !IsCheckoutOverrideScoped(name) { + return false + } + return mode == CheckoutOverrideStrict || mode == CheckoutOverrideFromJob +} + +// IsCheckoutLockedFromWithinJob reports whether a checkout-scoped var is locked +// against writes from within the running job (hooks, plugins, and the Job API) +// under the given mode. Vars that aren't checkout-scoped are governed by +// IsProtectedFromWithinJob instead. +func IsCheckoutLockedFromWithinJob(name string, mode CheckoutOverrideMode) bool { + if !IsCheckoutOverrideScoped(name) { + return false + } + return mode == CheckoutOverrideStrict +} diff --git a/env/protected_test.go b/env/protected_test.go index a99b86588c..34a8008033 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -16,6 +16,7 @@ func TestProtectedEnv(t *testing.T) { "BUILDKITE_BUILD_PATH", "BUILDKITE_COMMAND_EVAL", "BUILDKITE_CONFIG_PATH", + "BUILDKITE_CHECKOUT_OVERRIDE_MODE", "BUILDKITE_CONTAINER_COUNT", "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", "BUILDKITE_GIT_MIRRORS_PATH", @@ -24,7 +25,6 @@ func TestProtectedEnv(t *testing.T) { "BUILDKITE_HOOKS_PATH", "BUILDKITE_KUBERNETES_EXEC", "BUILDKITE_LOCAL_HOOKS_ENABLED", - "BUILDKITE_NO_CHECKOUT_OVERRIDE", "BUILDKITE_PLUGINS_ENABLED", "BUILDKITE_PLUGINS_PATH", "BUILDKITE_SHELL", @@ -86,9 +86,9 @@ func TestCheckoutOverrideScope(t *testing.T) { "BUILDKITE_SKIP_CHECKOUT", } - // These vars are conditionally locked: scoped, write-protected only when - // no-checkout-override is enabled. (Disjointness from protectedEnv is - // covered by TestProtectedAndCheckoutScopeDisjoint.) + // These vars are conditionally locked: scoped, write-protected depending on + // the checkout override mode. (Disjointness from protectedEnv is covered by + // TestProtectedAndCheckoutScopeDisjoint.) for _, envVar := range scoped { if got := IsCheckoutOverrideScoped(envVar); !got { t.Errorf("IsCheckoutOverrideScoped(%q) = false, want true", envVar) @@ -124,6 +124,91 @@ func TestCheckoutOverrideScope(t *testing.T) { } } +func TestParseCheckoutOverrideMode(t *testing.T) { + t.Parallel() + + cases := []struct { + in string + want CheckoutOverrideMode + wantErr bool + }{ + {"", CheckoutOverrideFromJob, false}, // empty selects the default + {"from-job", CheckoutOverrideFromJob, false}, + {"strict", CheckoutOverrideStrict, false}, + {"none", CheckoutOverrideNone, false}, + {"STRICT", CheckoutOverrideFromJob, true}, // case-sensitive + {"lockdown", CheckoutOverrideFromJob, true}, + } + + for _, tc := range cases { + got, err := ParseCheckoutOverrideMode(tc.in) + if (err != nil) != tc.wantErr { + t.Errorf("ParseCheckoutOverrideMode(%q) err = %v, wantErr %t", tc.in, err, tc.wantErr) + } + if got != tc.want { + t.Errorf("ParseCheckoutOverrideMode(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestCheckoutOverrideModeStringRoundTrip(t *testing.T) { + t.Parallel() + + for _, m := range []CheckoutOverrideMode{CheckoutOverrideFromJob, CheckoutOverrideStrict, CheckoutOverrideNone} { + got, err := ParseCheckoutOverrideMode(m.String()) + if err != nil { + t.Errorf("ParseCheckoutOverrideMode(%q): unexpected err %v", m.String(), err) + } + if got != m { + t.Errorf("round trip via %q = %v, want %v", m.String(), got, m) + } + } +} + +func TestCheckoutLockPredicates(t *testing.T) { + t.Parallel() + + const ( + scoped = "BUILDKITE_GIT_CLONE_FLAGS" // in checkoutOverrideScope + protected = "BUILDKITE_COMMAND_EVAL" // in protectedEnv, not scoped + unscoped = "MY_CUSTOM_VAR" // in neither map + ) + + // The matrix from the design: strict locks every source; from-job locks the + // outside-job sources (backend env, secrets) but leaves within-job sources + // (hooks, plugins, Job API) open, matching the agent's historical behaviour; + // none locks nothing. + cases := []struct { + mode CheckoutOverrideMode + wantFromJobEnv bool + wantWithinJob bool + }{ + {CheckoutOverrideStrict, true, true}, + {CheckoutOverrideFromJob, true, false}, + {CheckoutOverrideNone, false, false}, + } + + for _, tc := range cases { + if got := IsCheckoutLocked(scoped, tc.mode); got != tc.wantFromJobEnv { + t.Errorf("IsCheckoutLocked(%q, %v) = %t, want %t", scoped, tc.mode, got, tc.wantFromJobEnv) + } + if got := IsCheckoutLockedFromWithinJob(scoped, tc.mode); got != tc.wantWithinJob { + t.Errorf("IsCheckoutLockedFromWithinJob(%q, %v) = %t, want %t", scoped, tc.mode, got, tc.wantWithinJob) + } + + // Non-checkout-scoped vars are never governed by the checkout predicates, + // regardless of mode; protectedEnv handles them via IsProtected*. + for _, name := range []string{protected, unscoped} { + if IsCheckoutLocked(name, tc.mode) { + t.Errorf("IsCheckoutLocked(%q, %v) = true, want false", name, tc.mode) + } + if IsCheckoutLockedFromWithinJob(name, tc.mode) { + t.Errorf("IsCheckoutLockedFromWithinJob(%q, %v) = true, want false", name, tc.mode) + } + } + } +} + func TestProtectedAndCheckoutScopeDisjoint(t *testing.T) { t.Parallel() From 2b8e4d22955ced308b972414da2c58acb10d1cbe Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 13:45:46 -0400 Subject: [PATCH 31/57] Wire checkout-override-mode flag and enforcement --- agent/agent_configuration.go | 4 +- .../job_environment_integration_test.go | 147 ++++++++++-------- .../job_runner_integration_test.go | 13 +- agent/job_runner.go | 26 ++-- clicommand/agent_start.go | 30 ++-- clicommand/agent_start_test.go | 56 ++++--- clicommand/bootstrap.go | 29 ++-- clicommand/global.go | 9 +- internal/job/api.go | 4 +- internal/job/config.go | 12 +- internal/job/config_test.go | 38 +++-- internal/job/executor.go | 6 +- .../job/integration/hooks_integration_test.go | 63 ++++---- .../integration/plugin_integration_test.go | 26 ++-- .../integration/secrets_integration_test.go | 36 +++-- jobapi/env.go | 16 +- jobapi/server.go | 12 +- jobapi/server_test.go | 28 ++-- 18 files changed, 323 insertions(+), 232 deletions(-) diff --git a/agent/agent_configuration.go b/agent/agent_configuration.go index e327b69240..bdfb075365 100644 --- a/agent/agent_configuration.go +++ b/agent/agent_configuration.go @@ -3,6 +3,8 @@ package agent import ( "regexp" "time" + + "github.com/buildkite/agent/v3/env" ) // AgentConfiguration is the run-time configuration for an agent that @@ -31,7 +33,7 @@ type AgentConfiguration struct { GitSubmoduleCloneConfig []string SkipCheckout bool GitSkipFetchExistingCommits bool - NoCheckoutOverride bool + CheckoutOverrideMode env.CheckoutOverrideMode CheckoutAttempts int AllowedRepositories []*regexp.Regexp AllowedPlugins []*regexp.Regexp diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index 4616b3036f..2d4e1890ba 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -7,6 +7,7 @@ import ( "github.com/buildkite/agent/v3/agent" "github.com/buildkite/agent/v3/api" + "github.com/buildkite/agent/v3/env" "github.com/buildkite/agent/v3/logger" "github.com/buildkite/bintest/v3" "github.com/buildkite/go-pipeline" @@ -230,7 +231,7 @@ func TestBuildkiteRequestHeaders(t *testing.T) { } } -func TestCheckoutScopedJobEnvOverrideHonorsNoCheckoutOverride(t *testing.T) { +func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { t.Parallel() tests := []struct { @@ -242,150 +243,155 @@ func TestCheckoutScopedJobEnvOverrideHonorsNoCheckoutOverride(t *testing.T) { wantIgnoredEnvVars []string }{ { - name: "disabled_allows_job_env_to_override_clone_flags", + name: "none_allows_job_env_to_override_clone_flags", varName: "BUILDKITE_GIT_CLONE_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CLONE_FLAGS": "--no-tags", }, agentCfg: agent.AgentConfiguration{ - GitCloneFlags: "--mirror", + GitCloneFlags: "--mirror", + CheckoutOverrideMode: env.CheckoutOverrideNone, }, wantEnvValue: "--no-tags", }, { - name: "enabled_locks_clone_flags_to_agent_config", + name: "strict_locks_clone_flags_to_agent_config", varName: "BUILDKITE_GIT_CLONE_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CLONE_FLAGS": "--no-tags", }, agentCfg: agent.AgentConfiguration{ - GitCloneFlags: "--mirror", - NoCheckoutOverride: true, + GitCloneFlags: "--mirror", + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "--mirror", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_FLAGS"}, }, { - name: "disabled_allows_job_env_to_enable_submodules", + name: "none_allows_job_env_to_enable_submodules", varName: "BUILDKITE_GIT_SUBMODULES", jobEnv: map[string]string{ "BUILDKITE_GIT_SUBMODULES": "true", }, agentCfg: agent.AgentConfiguration{ - GitSubmodules: false, + GitSubmodules: false, + CheckoutOverrideMode: env.CheckoutOverrideNone, }, wantEnvValue: "true", }, { - name: "enabled_locks_submodules_to_agent_config", + name: "strict_locks_submodules_to_agent_config", varName: "BUILDKITE_GIT_SUBMODULES", jobEnv: map[string]string{ "BUILDKITE_GIT_SUBMODULES": "true", }, agentCfg: agent.AgentConfiguration{ - GitSubmodules: false, - NoCheckoutOverride: true, + GitSubmodules: false, + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "false", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SUBMODULES"}, }, { - name: "disabled_allows_job_env_to_override_skip_checkout", + name: "none_allows_job_env_to_override_skip_checkout", varName: "BUILDKITE_SKIP_CHECKOUT", jobEnv: map[string]string{ "BUILDKITE_SKIP_CHECKOUT": "false", }, agentCfg: agent.AgentConfiguration{ - SkipCheckout: true, + SkipCheckout: true, + CheckoutOverrideMode: env.CheckoutOverrideNone, }, wantEnvValue: "false", }, { - name: "enabled_locks_skip_checkout_to_agent_config", + name: "strict_locks_skip_checkout_to_agent_config", varName: "BUILDKITE_SKIP_CHECKOUT", jobEnv: map[string]string{ "BUILDKITE_SKIP_CHECKOUT": "false", }, agentCfg: agent.AgentConfiguration{ - SkipCheckout: true, - NoCheckoutOverride: true, + SkipCheckout: true, + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "true", wantIgnoredEnvVars: []string{"BUILDKITE_SKIP_CHECKOUT"}, }, { - name: "disabled_allows_job_env_to_override_sparse_checkout_paths", + name: "none_allows_job_env_to_override_sparse_checkout_paths", varName: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", jobEnv: map[string]string{ "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS": "job/path", }, agentCfg: agent.AgentConfiguration{ GitSparseCheckoutPaths: []string{"agent/path"}, + CheckoutOverrideMode: env.CheckoutOverrideNone, }, wantEnvValue: "job/path", }, { - name: "enabled_locks_sparse_checkout_paths_to_agent_config", + name: "strict_locks_sparse_checkout_paths_to_agent_config", varName: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", jobEnv: map[string]string{ "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS": "job/path", }, agentCfg: agent.AgentConfiguration{ GitSparseCheckoutPaths: []string{"agent/path"}, - NoCheckoutOverride: true, + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "agent/path", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS"}, }, // Inverse cases: when the agent config sits on the side that emits no var // by default, the lock must still force the agent value (regression for the - // leak where backend job env survived under no-checkout-override). + // leak where backend job env survived while checkout override was locked). { - name: "enabled_locks_submodules_on_to_agent_config", + name: "strict_locks_submodules_on_to_agent_config", varName: "BUILDKITE_GIT_SUBMODULES", jobEnv: map[string]string{ "BUILDKITE_GIT_SUBMODULES": "false", }, agentCfg: agent.AgentConfiguration{ - GitSubmodules: true, - NoCheckoutOverride: true, + GitSubmodules: true, + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "true", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SUBMODULES"}, }, { - name: "disabled_allows_job_env_to_disable_submodules", + name: "none_allows_job_env_to_disable_submodules", varName: "BUILDKITE_GIT_SUBMODULES", jobEnv: map[string]string{ "BUILDKITE_GIT_SUBMODULES": "false", }, agentCfg: agent.AgentConfiguration{ - GitSubmodules: true, + GitSubmodules: true, + CheckoutOverrideMode: env.CheckoutOverrideNone, }, wantEnvValue: "false", }, { - name: "enabled_locks_skip_checkout_off_to_agent_config", + name: "strict_locks_skip_checkout_off_to_agent_config", varName: "BUILDKITE_SKIP_CHECKOUT", jobEnv: map[string]string{ "BUILDKITE_SKIP_CHECKOUT": "true", }, agentCfg: agent.AgentConfiguration{ - SkipCheckout: false, - NoCheckoutOverride: true, + SkipCheckout: false, + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "false", wantIgnoredEnvVars: []string{"BUILDKITE_SKIP_CHECKOUT"}, }, { - name: "enabled_locks_skip_fetch_existing_commits_to_agent_config", + name: "strict_locks_skip_fetch_existing_commits_to_agent_config", varName: "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", jobEnv: map[string]string{ "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": "true", }, agentCfg: agent.AgentConfiguration{ GitSkipFetchExistingCommits: false, - NoCheckoutOverride: true, + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "false", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS"}, @@ -394,121 +400,126 @@ func TestCheckoutScopedJobEnvOverrideHonorsNoCheckoutOverride(t *testing.T) { // each in both directions so a regression in any one is caught. The git flag // vars are the injection vectors the lock exists to contain. { - name: "disabled_allows_job_env_to_override_checkout_flags", + name: "none_allows_job_env_to_override_checkout_flags", varName: "BUILDKITE_GIT_CHECKOUT_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CHECKOUT_FLAGS": "--quiet", }, agentCfg: agent.AgentConfiguration{ - GitCheckoutFlags: "-f", + GitCheckoutFlags: "-f", + CheckoutOverrideMode: env.CheckoutOverrideNone, }, wantEnvValue: "--quiet", }, { - name: "enabled_locks_checkout_flags_to_agent_config", + name: "strict_locks_checkout_flags_to_agent_config", varName: "BUILDKITE_GIT_CHECKOUT_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CHECKOUT_FLAGS": "--quiet", }, agentCfg: agent.AgentConfiguration{ - GitCheckoutFlags: "-f", - NoCheckoutOverride: true, + GitCheckoutFlags: "-f", + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "-f", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CHECKOUT_FLAGS"}, }, { - name: "disabled_allows_job_env_to_override_fetch_flags", + name: "none_allows_job_env_to_override_fetch_flags", varName: "BUILDKITE_GIT_FETCH_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_FETCH_FLAGS": "--prune", }, agentCfg: agent.AgentConfiguration{ - GitFetchFlags: "-v", + GitFetchFlags: "-v", + CheckoutOverrideMode: env.CheckoutOverrideNone, }, wantEnvValue: "--prune", }, { - name: "enabled_locks_fetch_flags_to_agent_config", + name: "strict_locks_fetch_flags_to_agent_config", varName: "BUILDKITE_GIT_FETCH_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_FETCH_FLAGS": "--prune", }, agentCfg: agent.AgentConfiguration{ - GitFetchFlags: "-v", - NoCheckoutOverride: true, + GitFetchFlags: "-v", + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "-v", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_FETCH_FLAGS"}, }, { - name: "disabled_allows_job_env_to_override_clean_flags", + name: "none_allows_job_env_to_override_clean_flags", varName: "BUILDKITE_GIT_CLEAN_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CLEAN_FLAGS": "-fdq", }, agentCfg: agent.AgentConfiguration{ - GitCleanFlags: "-ffxdq", + GitCleanFlags: "-ffxdq", + CheckoutOverrideMode: env.CheckoutOverrideNone, }, wantEnvValue: "-fdq", }, { - name: "enabled_locks_clean_flags_to_agent_config", + name: "strict_locks_clean_flags_to_agent_config", varName: "BUILDKITE_GIT_CLEAN_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CLEAN_FLAGS": "-fdq", }, agentCfg: agent.AgentConfiguration{ - GitCleanFlags: "-ffxdq", - NoCheckoutOverride: true, + GitCleanFlags: "-ffxdq", + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "-ffxdq", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLEAN_FLAGS"}, }, { - name: "disabled_allows_job_env_to_override_clone_mirror_flags", + name: "none_allows_job_env_to_override_clone_mirror_flags", varName: "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": "--mirror", }, agentCfg: agent.AgentConfiguration{ - GitCloneMirrorFlags: "--bare", + GitCloneMirrorFlags: "--bare", + CheckoutOverrideMode: env.CheckoutOverrideNone, }, wantEnvValue: "--mirror", }, { - name: "enabled_locks_clone_mirror_flags_to_agent_config", + name: "strict_locks_clone_mirror_flags_to_agent_config", varName: "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": "--mirror", }, agentCfg: agent.AgentConfiguration{ - GitCloneMirrorFlags: "--bare", - NoCheckoutOverride: true, + GitCloneMirrorFlags: "--bare", + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "--bare", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_MIRROR_FLAGS"}, }, { - name: "disabled_allows_job_env_to_override_mirrors_skip_update", + name: "none_allows_job_env_to_override_mirrors_skip_update", varName: "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", jobEnv: map[string]string{ "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": "true", }, agentCfg: agent.AgentConfiguration{ GitMirrorsSkipUpdate: false, + CheckoutOverrideMode: env.CheckoutOverrideNone, }, wantEnvValue: "true", }, { - name: "enabled_locks_mirrors_skip_update_to_agent_config", + name: "strict_locks_mirrors_skip_update_to_agent_config", varName: "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", jobEnv: map[string]string{ "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": "true", }, agentCfg: agent.AgentConfiguration{ GitMirrorsSkipUpdate: false, - NoCheckoutOverride: true, + CheckoutOverrideMode: env.CheckoutOverrideStrict, }, wantEnvValue: "false", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_MIRRORS_SKIP_UPDATE"}, @@ -582,7 +593,7 @@ func TestCheckoutInfraVarsAreAgentAuthoritative(t *testing.T) { // SSH_KEYSCAN, GIT_MIRRORS_PATH, GIT_MIRRORS_LOCK_TIMEOUT and // GIT_MIRROR_CHECKOUT_MODE are agent-only: job env cannot override them even - // with no-checkout-override disabled. + // under the most permissive checkout-override mode (none). tests := []struct { name string varName string @@ -594,28 +605,28 @@ func TestCheckoutInfraVarsAreAgentAuthoritative(t *testing.T) { name: "ssh_keyscan", varName: "BUILDKITE_SSH_KEYSCAN", jobEnvValue: "false", - agentCfg: agent.AgentConfiguration{SSHKeyscan: true}, + agentCfg: agent.AgentConfiguration{SSHKeyscan: true, CheckoutOverrideMode: env.CheckoutOverrideNone}, wantEnvValue: "true", }, { name: "git_mirrors_path", varName: "BUILDKITE_GIT_MIRRORS_PATH", jobEnvValue: "/tmp/attacker-mirrors", - agentCfg: agent.AgentConfiguration{GitMirrorsPath: "/agent/mirrors"}, + agentCfg: agent.AgentConfiguration{GitMirrorsPath: "/agent/mirrors", CheckoutOverrideMode: env.CheckoutOverrideNone}, wantEnvValue: "/agent/mirrors", }, { name: "git_mirrors_lock_timeout", varName: "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", jobEnvValue: "1", - agentCfg: agent.AgentConfiguration{GitMirrorsLockTimeout: 300}, + agentCfg: agent.AgentConfiguration{GitMirrorsLockTimeout: 300, CheckoutOverrideMode: env.CheckoutOverrideNone}, wantEnvValue: "300", }, { name: "git_mirror_checkout_mode", varName: "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", jobEnvValue: "id", - agentCfg: agent.AgentConfiguration{GitMirrorCheckoutMode: "raw"}, + agentCfg: agent.AgentConfiguration{GitMirrorCheckoutMode: "raw", CheckoutOverrideMode: env.CheckoutOverrideNone}, wantEnvValue: "raw", }, } @@ -671,18 +682,18 @@ func TestCheckoutInfraVarsAreAgentAuthoritative(t *testing.T) { } } -func TestNoCheckoutOverrideFlagIgnoresJobEnvOverride(t *testing.T) { +func TestCheckoutOverrideModeIgnoresJobEnvOverride(t *testing.T) { t.Parallel() - // The agent's no-checkout-override setting is authoritative: a job that - // supplies BUILDKITE_NO_CHECKOUT_OVERRIDE cannot turn the lock off. + // The agent's checkout-override mode is authoritative: a job that supplies + // BUILDKITE_CHECKOUT_OVERRIDE_MODE cannot relax the lock. ctx := t.Context() job := &api.Job{ ID: "my-job-id", ChunksMaxSizeBytes: 1024, Env: map[string]string{ - "BUILDKITE_COMMAND": "echo hello world", - "BUILDKITE_NO_CHECKOUT_OVERRIDE": "false", + "BUILDKITE_COMMAND": "echo hello world", + "BUILDKITE_CHECKOUT_OVERRIDE_MODE": "none", }, Token: "bkaj_job-token", } @@ -691,15 +702,15 @@ func TestNoCheckoutOverrideFlagIgnoresJobEnvOverride(t *testing.T) { defer mb.CheckAndClose(t) //nolint:errcheck // bintest logs to t mb.Expect().Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { - if got, want := c.GetEnv("BUILDKITE_NO_CHECKOUT_OVERRIDE"), "true"; got != want { - t.Errorf("c.GetEnv(BUILDKITE_NO_CHECKOUT_OVERRIDE) = %q, want %q", got, want) + if got, want := c.GetEnv("BUILDKITE_CHECKOUT_OVERRIDE_MODE"), "strict"; got != want { + t.Errorf("c.GetEnv(BUILDKITE_CHECKOUT_OVERRIDE_MODE) = %q, want %q", got, want) c.Exit(1) return } ignored := strings.Split(strings.TrimSpace(c.GetEnv("BUILDKITE_IGNORED_ENV")), ",") - if !slices.Contains(ignored, "BUILDKITE_NO_CHECKOUT_OVERRIDE") { - t.Errorf("BUILDKITE_IGNORED_ENV = %q, want it to contain BUILDKITE_NO_CHECKOUT_OVERRIDE", c.GetEnv("BUILDKITE_IGNORED_ENV")) + if !slices.Contains(ignored, "BUILDKITE_CHECKOUT_OVERRIDE_MODE") { + t.Errorf("BUILDKITE_IGNORED_ENV = %q, want it to contain BUILDKITE_CHECKOUT_OVERRIDE_MODE", c.GetEnv("BUILDKITE_IGNORED_ENV")) c.Exit(1) return } @@ -714,7 +725,7 @@ func TestNoCheckoutOverrideFlagIgnoresJobEnvOverride(t *testing.T) { if err := runJob(t, ctx, testRunJobConfig{ job: job, server: server, - agentCfg: agent.AgentConfiguration{NoCheckoutOverride: true}, + agentCfg: agent.AgentConfiguration{CheckoutOverrideMode: env.CheckoutOverrideStrict}, mockBootstrap: mb, }); err != nil { t.Fatalf("runJob() error = %v", err) diff --git a/agent/integration/job_runner_integration_test.go b/agent/integration/job_runner_integration_test.go index 5913f5850f..6a3a8dcff4 100644 --- a/agent/integration/job_runner_integration_test.go +++ b/agent/integration/job_runner_integration_test.go @@ -16,6 +16,7 @@ import ( "github.com/buildkite/agent/v3/agent" "github.com/buildkite/agent/v3/api" + "github.com/buildkite/agent/v3/env" "github.com/buildkite/agent/v3/internal/experiments" "github.com/buildkite/bintest/v3" ) @@ -281,7 +282,7 @@ func TestJobRunner_WhenJobHasToken_ItOverridesAccessToken(t *testing.T) { } } -func TestJobRunnerPassesNoCheckoutOverrideToBootstrapAndEnvFile(t *testing.T) { +func TestJobRunnerPassesCheckoutOverrideModeToBootstrapAndEnvFile(t *testing.T) { t.Parallel() ctx, _ := experiments.Enable(t.Context(), experiments.PropagateAgentConfigVars) @@ -303,8 +304,8 @@ func TestJobRunnerPassesNoCheckoutOverrideToBootstrapAndEnvFile(t *testing.T) { }) mb.Expect().Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { - if got, want := c.GetEnv("BUILDKITE_NO_CHECKOUT_OVERRIDE"), "true"; got != want { - t.Errorf("c.GetEnv(BUILDKITE_NO_CHECKOUT_OVERRIDE) = %q, want %q", got, want) + if got, want := c.GetEnv("BUILDKITE_CHECKOUT_OVERRIDE_MODE"), "strict"; got != want { + t.Errorf("c.GetEnv(BUILDKITE_CHECKOUT_OVERRIDE_MODE) = %q, want %q", got, want) c.Exit(1) return } @@ -317,8 +318,8 @@ func TestJobRunnerPassesNoCheckoutOverrideToBootstrapAndEnvFile(t *testing.T) { return } - if !strings.Contains(string(contents), "BUILDKITE_NO_CHECKOUT_OVERRIDE") { - t.Errorf("env file %q did not contain BUILDKITE_NO_CHECKOUT_OVERRIDE", envFile) + if !strings.Contains(string(contents), "BUILDKITE_CHECKOUT_OVERRIDE_MODE") { + t.Errorf("env file %q did not contain BUILDKITE_CHECKOUT_OVERRIDE_MODE", envFile) c.Exit(1) return } @@ -333,7 +334,7 @@ func TestJobRunnerPassesNoCheckoutOverrideToBootstrapAndEnvFile(t *testing.T) { err := runJob(t, ctx, testRunJobConfig{ job: j, server: server, - agentCfg: agent.AgentConfiguration{NoCheckoutOverride: true}, + agentCfg: agent.AgentConfiguration{CheckoutOverrideMode: env.CheckoutOverrideStrict}, mockBootstrap: mb, }) if err != nil { diff --git a/agent/job_runner.go b/agent/job_runner.go index 5afac23199..5665c32170 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -405,11 +405,17 @@ func (r *JobRunner) normalizeVerificationBehavior(behavior string) (string, erro // Creates the environment variables that will be used in the process and writes a flat environment file func (r *JobRunner) createEnvironment(ctx context.Context) ([]string, error) { + // Captured before the local env map below shadows the env package name. + // Checkout-scoped vars are locked against backend job env under strict and + // from-job (i.e. every mode except none). + checkoutMode := r.conf.AgentConfiguration.CheckoutOverrideMode + checkoutLockedFromJobEnv := checkoutMode != envutil.CheckoutOverrideNone + // Create a clone of our jobs environment. We'll then set the // environment variables provided by the agent, which will override any // sent by Buildkite. The variables below should always take precedence, // except the checkout-scoped vars set via setCheckoutEnv, which defer to - // the Buildkite-sent value unless no-checkout-override is enabled. + // the Buildkite-sent value unless the checkout-override mode locks them. env := make(map[string]string) maps.Copy(env, r.conf.Job.Env) @@ -441,11 +447,11 @@ func (r *JobRunner) createEnvironment(ctx context.Context) ([]string, error) { } env[name] = value } - // For some checkout env vars, we allow the Job env (if present) to - // have higher precedence than the agent configuration, unless - // NoCheckoutOverride is set. + // For some checkout env vars, we allow the Job env (if present) to have + // higher precedence than the agent configuration, unless the checkout-override + // mode locks them. Only checkout-scoped vars are passed here. setCheckoutEnv := func(name, value string) { - if !r.conf.AgentConfiguration.NoCheckoutOverride { + if !checkoutLockedFromJobEnv { if _, exists := env[name]; exists { return } @@ -479,7 +485,7 @@ BUILDKITE_GIT_MIRRORS_PATH BUILDKITE_GIT_MIRRORS_SKIP_UPDATE BUILDKITE_GIT_SUBMODULES BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG -BUILDKITE_NO_CHECKOUT_OVERRIDE +BUILDKITE_CHECKOUT_OVERRIDE_MODE BUILDKITE_CANCEL_GRACE_PERIOD BUILDKITE_COMMAND_EVAL BUILDKITE_LOCAL_HOOKS_ENABLED @@ -612,9 +618,9 @@ BUILDKITE_AGENT_JWKS_KEY_ID` // These three vars are only emitted by the agent on one side of their // default (submodules off, skip-checkout on, skip-fetch on); on the other // side the agent stays silent and lets pipeline/step env decide. That silent - // side would leak past no-checkout-override, so when the lock is on we emit - // the agent value unconditionally to keep agent config authoritative. - if r.conf.AgentConfiguration.NoCheckoutOverride { + // side would leak past the checkout-override lock, so when the lock is on we + // emit the agent value unconditionally to keep agent config authoritative. + if checkoutLockedFromJobEnv { setEnv("BUILDKITE_GIT_SUBMODULES", fmt.Sprint(r.conf.AgentConfiguration.GitSubmodules)) setEnv("BUILDKITE_SKIP_CHECKOUT", fmt.Sprint(r.conf.AgentConfiguration.SkipCheckout)) setEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", fmt.Sprint(r.conf.AgentConfiguration.GitSkipFetchExistingCommits)) @@ -634,7 +640,7 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setCheckoutEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", "true") } } - setEnv("BUILDKITE_NO_CHECKOUT_OVERRIDE", fmt.Sprint(r.conf.AgentConfiguration.NoCheckoutOverride)) + setEnv("BUILDKITE_CHECKOUT_OVERRIDE_MODE", checkoutMode.String()) setEnv("BUILDKITE_CHECKOUT_ATTEMPTS", strconv.Itoa(r.conf.AgentConfiguration.CheckoutAttempts)) setEnv("BUILDKITE_COMMAND_EVAL", fmt.Sprint(r.conf.AgentConfiguration.CommandEval)) setEnv("BUILDKITE_PLUGINS_ENABLED", fmt.Sprint(r.conf.AgentConfiguration.PluginsEnabled)) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index 2ef93f6704..1f92a0f60d 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -78,6 +78,8 @@ var ( mirrorCheckoutModes = []string{"dissociate", "reference"} + checkoutOverrideModes = env.CheckoutOverrideModeNames + buildkiteSetEnvironmentVariables = []*regexp.Regexp{ regexp.MustCompile("^BUILDKITE$"), regexp.MustCompile("^BUILDKITE_.*$"), @@ -175,7 +177,7 @@ type AgentStartConfig struct { GitSubmoduleCloneConfig []string `cli:"git-submodule-clone-config"` SkipCheckout bool `cli:"skip-checkout"` GitSkipFetchExistingCommits bool `cli:"git-skip-fetch-existing-commits"` - NoCheckoutOverride bool `cli:"no-checkout-override"` + CheckoutOverrideMode string `cli:"checkout-override-mode"` CheckoutAttempts int `cli:"checkout-attempts"` NoSSHKeyscan bool `cli:"no-ssh-keyscan"` @@ -232,14 +234,17 @@ type AgentStartConfig struct { DisconnectAfterJobTimeout int `cli:"disconnect-after-job-timeout" deprecated:"Use disconnect-after-idle-timeout instead"` } -// lockCheckoutWhenCommandEvalDisabled forces no-checkout-override on when -// command-eval is disabled, so git flags can't be used to bypass it. -// AgentStartConfig stores this as NoCommandEval; the BootstrapConfig sibling -// uses CommandEval, so its check is inverted. Keep the two in sync. -func (cfg *AgentStartConfig) lockCheckoutWhenCommandEvalDisabled() { - if cfg.NoCommandEval { - cfg.NoCheckoutOverride = true +// checkoutOverrideMode parses the configured checkout-override mode and floors +// it at from-job when command-eval is disabled, so a job can't use backend env +// or secret git flags to bypass no-command-eval. AgentStartConfig stores +// NoCommandEval; the BootstrapConfig sibling uses CommandEval, so its check is +// inverted. Keep the two in sync. +func (cfg *AgentStartConfig) checkoutOverrideMode() (env.CheckoutOverrideMode, error) { + mode, err := env.ParseCheckoutOverrideMode(cfg.CheckoutOverrideMode) + if err != nil { + return mode, err } + return mode.FlooredForCommandEval(!cfg.NoCommandEval), nil } func (asc AgentStartConfig) Features(ctx context.Context) []string { @@ -547,7 +552,7 @@ var AgentStartCommand = cli.Command{ // Various git related flags shared with bootstrap SkipCheckoutFlag, - NoCheckoutOverrideFlag, + CheckoutOverrideModeFlag, GitCheckoutFlagsFlag, GitCloneFlagsFlag, GitCleanFlagsFlag, @@ -948,7 +953,10 @@ var AgentStartCommand = cli.Command{ cfg.NoPlugins = true } - cfg.lockCheckoutWhenCommandEvalDisabled() + checkoutMode, err := cfg.checkoutOverrideMode() + if err != nil { + return err + } // Guess the shell if none is provided if cfg.Shell == "" { @@ -1122,7 +1130,7 @@ var AgentStartCommand = cli.Command{ GitSubmoduleCloneConfig: cfg.GitSubmoduleCloneConfig, SkipCheckout: cfg.SkipCheckout, GitSkipFetchExistingCommits: cfg.GitSkipFetchExistingCommits, - NoCheckoutOverride: cfg.NoCheckoutOverride, + CheckoutOverrideMode: checkoutMode, CheckoutAttempts: cfg.CheckoutAttempts, SSHKeyscan: !cfg.NoSSHKeyscan, CommandEval: !cfg.NoCommandEval, diff --git a/clicommand/agent_start_test.go b/clicommand/agent_start_test.go index 10041d0c7d..2265f2b95a 100644 --- a/clicommand/agent_start_test.go +++ b/clicommand/agent_start_test.go @@ -10,6 +10,7 @@ import ( "github.com/buildkite/agent/v3/agent" "github.com/buildkite/agent/v3/api" "github.com/buildkite/agent/v3/core" + "github.com/buildkite/agent/v3/env" "github.com/buildkite/agent/v3/logger" "github.com/google/go-cmp/cmp" "github.com/urfave/cli" @@ -376,53 +377,70 @@ func TestAgentStartJobAcquisitionRejected_ExitCode27(t *testing.T) { } } -func TestAgentStartLockCheckoutWhenCommandEvalDisabled(t *testing.T) { +func TestAgentStartCheckoutOverrideMode(t *testing.T) { t.Parallel() // AgentStartConfig uses NoCommandEval, so the zero value leaves command-eval - // enabled and the lock off. + // enabled; none is only floored to from-job when command-eval is disabled. tests := []struct { name string cfg AgentStartConfig - want bool + want env.CheckoutOverrideMode }{ - {name: "explicit_flag", cfg: AgentStartConfig{NoCheckoutOverride: true}, want: true}, - {name: "no_command_eval_forces_lock", cfg: AgentStartConfig{NoCommandEval: true}, want: true}, - {name: "defaults_unlocked", cfg: AgentStartConfig{}, want: false}, + {name: "default_from_job", cfg: AgentStartConfig{}, want: env.CheckoutOverrideFromJob}, + {name: "explicit_strict", cfg: AgentStartConfig{CheckoutOverrideMode: "strict"}, want: env.CheckoutOverrideStrict}, + {name: "explicit_none", cfg: AgentStartConfig{CheckoutOverrideMode: "none"}, want: env.CheckoutOverrideNone}, + {name: "no_command_eval_floors_none_to_from_job", cfg: AgentStartConfig{CheckoutOverrideMode: "none", NoCommandEval: true}, want: env.CheckoutOverrideFromJob}, + {name: "no_command_eval_leaves_strict", cfg: AgentStartConfig{CheckoutOverrideMode: "strict", NoCommandEval: true}, want: env.CheckoutOverrideStrict}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - tc.cfg.lockCheckoutWhenCommandEvalDisabled() - if got := tc.cfg.NoCheckoutOverride; got != tc.want { - t.Errorf("NoCheckoutOverride = %v, want %v", got, tc.want) + got, err := tc.cfg.checkoutOverrideMode() + if err != nil { + t.Fatalf("checkoutOverrideMode() error = %v", err) + } + if got != tc.want { + t.Errorf("checkoutOverrideMode() = %v, want %v", got, tc.want) } }) } } -func TestBootstrapLockCheckoutWhenCommandEvalDisabled(t *testing.T) { +func TestAgentStartCheckoutOverrideModeInvalid(t *testing.T) { + t.Parallel() + cfg := AgentStartConfig{CheckoutOverrideMode: "bogus"} + if _, err := cfg.checkoutOverrideMode(); err == nil { + t.Error("checkoutOverrideMode() with invalid mode: want error, got nil") + } +} + +func TestBootstrapCheckoutOverrideMode(t *testing.T) { t.Parallel() - // BootstrapConfig uses CommandEval, so the zero value disables command-eval - // and forces the lock on; "unlocked" cases must set CommandEval explicitly. + // BootstrapConfig uses CommandEval; the zero value disables command-eval and + // floors none to from-job, so cases that keep none must set CommandEval. tests := []struct { name string cfg BootstrapConfig - want bool + want env.CheckoutOverrideMode }{ - {name: "explicit_flag", cfg: BootstrapConfig{NoCheckoutOverride: true, CommandEval: true}, want: true}, - {name: "command_eval_disabled_forces_lock", cfg: BootstrapConfig{CommandEval: false}, want: true}, - {name: "defaults_unlocked", cfg: BootstrapConfig{CommandEval: true}, want: false}, + {name: "default_from_job", cfg: BootstrapConfig{CommandEval: true}, want: env.CheckoutOverrideFromJob}, + {name: "explicit_strict", cfg: BootstrapConfig{CheckoutOverrideMode: "strict", CommandEval: true}, want: env.CheckoutOverrideStrict}, + {name: "explicit_none", cfg: BootstrapConfig{CheckoutOverrideMode: "none", CommandEval: true}, want: env.CheckoutOverrideNone}, + {name: "command_eval_disabled_floors_none_to_from_job", cfg: BootstrapConfig{CheckoutOverrideMode: "none", CommandEval: false}, want: env.CheckoutOverrideFromJob}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - tc.cfg.lockCheckoutWhenCommandEvalDisabled() - if got := tc.cfg.NoCheckoutOverride; got != tc.want { - t.Errorf("NoCheckoutOverride = %v, want %v", got, tc.want) + got, err := tc.cfg.checkoutOverrideMode() + if err != nil { + t.Fatalf("checkoutOverrideMode() error = %v", err) + } + if got != tc.want { + t.Errorf("checkoutOverrideMode() = %v, want %v", got, tc.want) } }) } diff --git a/clicommand/bootstrap.go b/clicommand/bootstrap.go index 0821a726bb..d0de9f563b 100644 --- a/clicommand/bootstrap.go +++ b/clicommand/bootstrap.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + "github.com/buildkite/agent/v3/env" "github.com/buildkite/agent/v3/internal/job" "github.com/buildkite/agent/v3/internal/process" "github.com/buildkite/agent/v3/internal/self" @@ -86,7 +87,7 @@ type BootstrapConfig struct { GitMirrorsLockTimeout int `cli:"git-mirrors-lock-timeout"` GitMirrorsSkipUpdate bool `cli:"git-mirrors-skip-update"` GitSubmoduleCloneConfig []string `cli:"git-submodule-clone-config" normalize:"list"` - NoCheckoutOverride bool `cli:"no-checkout-override"` + CheckoutOverrideMode string `cli:"checkout-override-mode"` BinPath string `cli:"bin-path" normalize:"filepath"` BuildPath string `cli:"build-path" normalize:"filepath"` HooksPath string `cli:"hooks-path" normalize:"filepath"` @@ -122,14 +123,17 @@ type BootstrapConfig struct { CheckoutAttempts int `cli:"checkout-attempts"` } -// lockCheckoutWhenCommandEvalDisabled forces no-checkout-override on when -// command-eval is disabled, so git flags can't be used to bypass it. -// BootstrapConfig stores this as CommandEval; the AgentStartConfig sibling uses -// NoCommandEval, so its check is inverted. Keep the two in sync. -func (cfg *BootstrapConfig) lockCheckoutWhenCommandEvalDisabled() { - if !cfg.CommandEval { - cfg.NoCheckoutOverride = true +// checkoutOverrideMode parses the configured checkout-override mode and floors +// it at from-job when command-eval is disabled, so a job can't use backend env +// or secret git flags to bypass no-command-eval. BootstrapConfig stores +// CommandEval; the AgentStartConfig sibling uses NoCommandEval, so its check is +// inverted. Keep the two in sync. +func (cfg *BootstrapConfig) checkoutOverrideMode() (env.CheckoutOverrideMode, error) { + mode, err := env.ParseCheckoutOverrideMode(cfg.CheckoutOverrideMode) + if err != nil { + return mode, err } + return mode.FlooredForCommandEval(cfg.CommandEval), nil } var BootstrapCommand = cli.Command{ @@ -253,7 +257,7 @@ var BootstrapCommand = cli.Command{ // Various git related flags shared with agent start SkipCheckoutFlag, - NoCheckoutOverrideFlag, + CheckoutOverrideModeFlag, GitCheckoutFlagsFlag, GitCloneFlagsFlag, GitCloneMirrorFlagsFlag, @@ -454,7 +458,10 @@ var BootstrapCommand = cli.Command{ return fmt.Errorf("while parsing trace context encoding: %v", err) } - cfg.lockCheckoutWhenCommandEvalDisabled() + checkoutMode, err := cfg.checkoutOverrideMode() + if err != nil { + return err + } // Configure the bootstraper bootstrap := job.New(job.ExecutorConfig{ @@ -471,7 +478,7 @@ var BootstrapCommand = cli.Command{ SkipCheckout: cfg.SkipCheckout, GitCheckoutTimeout: cfg.GitCheckoutTimeout, GitSkipFetchExistingCommits: cfg.GitSkipFetchExistingCommits, - NoCheckoutOverride: cfg.NoCheckoutOverride, + CheckoutOverrideMode: checkoutMode, Command: cfg.Command, CommandEval: cfg.CommandEval, Commit: cfg.Commit, diff --git a/clicommand/global.go b/clicommand/global.go index 02e8833382..907191d002 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -205,10 +205,11 @@ var ( EnvVar: "BUILDKITE_SKIP_CHECKOUT", } - NoCheckoutOverrideFlag = cli.BoolFlag{ - Name: "no-checkout-override", - Usage: "Don't allow pipeline/step env, hooks, plugins, the Job API, or secrets to override the agent's checkout settings (default: false)", - EnvVar: "BUILDKITE_NO_CHECKOUT_OVERRIDE", + CheckoutOverrideModeFlag = cli.StringFlag{ + Name: "checkout-override-mode", + Value: "from-job", + Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) blocks pipeline/step env and secrets but lets hooks, plugins, and the Job API set checkout vars. ′none′ lets any source override. Disabling command-eval floors this at ′from-job′.", checkoutOverrideModes), + EnvVar: "BUILDKITE_CHECKOUT_OVERRIDE_MODE", } GitCheckoutFlagsFlag = cli.StringFlag{ diff --git a/internal/job/api.go b/internal/job/api.go index a0f1872774..b01a46ddf7 100644 --- a/internal/job/api.go +++ b/internal/job/api.go @@ -36,9 +36,7 @@ We'll continue to run your job, but you won't be able to use the Job API`) if e.Debug { jobAPIOpts = append(jobAPIOpts, jobapi.WithDebug()) } - if e.NoCheckoutOverride { - jobAPIOpts = append(jobAPIOpts, jobapi.WithNoCheckoutOverride()) - } + jobAPIOpts = append(jobAPIOpts, jobapi.WithCheckoutOverrideMode(e.CheckoutOverrideMode)) srv, token, err := jobapi.NewServer(e.shell.Logger, socketPath, e.shell.Env, e.redactors, jobAPIOpts...) if err != nil { return cleanup, fmt.Errorf("creating job API server: %w", err) diff --git a/internal/job/config.go b/internal/job/config.go index 9311e8184a..699a51e97a 100644 --- a/internal/job/config.go +++ b/internal/job/config.go @@ -90,9 +90,9 @@ type ExecutorConfig struct { // Skip git fetch if the commit already exists locally GitSkipFetchExistingCommits bool `env:"BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS"` - // Lock the agent's checkout settings so the job cannot override them. - // Intentionally has no env tag so hooks cannot disable it at runtime. - NoCheckoutOverride bool + // Controls which sources may override the agent's checkout settings. + // Intentionally has no env tag so hooks cannot relax it at runtime. + CheckoutOverrideMode env.CheckoutOverrideMode // Timeout in seconds for the git checkout phase (0 means no timeout) GitCheckoutTimeout int `env:"BUILDKITE_GIT_CHECKOUT_TIMEOUT"` @@ -251,7 +251,11 @@ func (c *ExecutorConfig) ReadFromEnvironment(environ *env.Environment) map[strin // Find struct fields with env tag if tag := f.Tag.Get("env"); tag != "" && environ.Exists(tag) { - if c.NoCheckoutOverride && env.IsCheckoutOverrideScoped(tag) { + // ReadFromEnvironment runs after applyEnvironmentChanges, so the + // checkout vars here come from within the job (hooks/plugins). Use the + // within-job predicate: from-job lets them reconfigure checkout, strict + // does not. + if env.IsCheckoutLockedFromWithinJob(tag, c.CheckoutOverrideMode) { continue } diff --git a/internal/job/config_test.go b/internal/job/config_test.go index fd4c217048..9ebf03e717 100644 --- a/internal/job/config_test.go +++ b/internal/job/config_test.go @@ -112,28 +112,28 @@ func TestReadFromEnvironmentIgnoresMalformedBooleans(t *testing.T) { } } -func TestReadFromEnvironmentDoesNotRefreshNoCheckoutOverride(t *testing.T) { +func TestReadFromEnvironmentDoesNotRefreshCheckoutOverrideMode(t *testing.T) { t.Parallel() - config := &ExecutorConfig{NoCheckoutOverride: true} - environ := env.FromSlice([]string{"BUILDKITE_NO_CHECKOUT_OVERRIDE=false"}) + config := &ExecutorConfig{CheckoutOverrideMode: env.CheckoutOverrideStrict} + environ := env.FromSlice([]string{"BUILDKITE_CHECKOUT_OVERRIDE_MODE=none"}) changes := config.ReadFromEnvironment(environ) if len(changes) != 0 { t.Errorf("changes = %v, want none", changes) } - if got, want := config.NoCheckoutOverride, true; got != want { - t.Errorf("config.NoCheckoutOverride = %t, want %t", got, want) + if got, want := config.CheckoutOverrideMode, env.CheckoutOverrideStrict; got != want { + t.Errorf("config.CheckoutOverrideMode = %v, want %v", got, want) } } -func TestReadFromEnvironmentSkipsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *testing.T) { +func TestReadFromEnvironmentSkipsCheckoutScopedVarsWhenCheckoutLocked(t *testing.T) { t.Parallel() config := &ExecutorConfig{ - NoCheckoutOverride: true, - SkipCheckout: false, - GitCloneFlags: "-v", + CheckoutOverrideMode: env.CheckoutOverrideStrict, + SkipCheckout: false, + GitCloneFlags: "-v", } environ := env.FromSlice([]string{ "BUILDKITE_SKIP_CHECKOUT=true", @@ -152,6 +152,26 @@ func TestReadFromEnvironmentSkipsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled } } +func TestReadFromEnvironmentRefreshesCheckoutScopedVarsUnderFromJob(t *testing.T) { + t.Parallel() + + // from-job lets hooks/plugins reconfigure checkout, so ReadFromEnvironment + // must apply their checkout-scoped changes rather than skip them. + config := &ExecutorConfig{ + CheckoutOverrideMode: env.CheckoutOverrideFromJob, + GitCloneFlags: "-v", + } + environ := env.FromSlice([]string{"BUILDKITE_GIT_CLONE_FLAGS=--mirror"}) + + changes := config.ReadFromEnvironment(environ) + if got, want := config.GitCloneFlags, "--mirror"; got != want { + t.Errorf("config.GitCloneFlags = %q, want %q", got, want) + } + if _, ok := changes["BUILDKITE_GIT_CLONE_FLAGS"]; !ok { + t.Errorf("changes = %v, want it to contain BUILDKITE_GIT_CLONE_FLAGS", changes) + } +} + func TestGitSubmodulesBidirectionalControl(t *testing.T) { t.Parallel() diff --git a/internal/job/executor.go b/internal/job/executor.go index 27c1bcd5db..71d6b66d2f 100644 --- a/internal/job/executor.go +++ b/internal/job/executor.go @@ -649,7 +649,7 @@ func (e *Executor) applyEnvironmentChanges(changes hook.EnvChanges) { var protected []string for k := range changes.Diff.Keys { if env.IsProtectedFromWithinJob(k) || - (e.NoCheckoutOverride && env.IsCheckoutOverrideScoped(k)) { + env.IsCheckoutLockedFromWithinJob(k, e.CheckoutOverrideMode) { protected = append(protected, k) changes.Diff.Remove(k) } @@ -994,8 +994,8 @@ func (e *Executor) fetchAndSetSecrets(ctx context.Context) error { if env.IsProtected(pipelineSecret.EnvironmentVariable) { return fmt.Errorf("secret %q cannot set protected environment variable %q", pipelineSecret.Key, pipelineSecret.EnvironmentVariable) } - if e.NoCheckoutOverride && env.IsCheckoutOverrideScoped(pipelineSecret.EnvironmentVariable) { - return fmt.Errorf("secret %q cannot set checkout-locked environment variable %q while BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled", pipelineSecret.Key, pipelineSecret.EnvironmentVariable) + if env.IsCheckoutLocked(pipelineSecret.EnvironmentVariable, e.CheckoutOverrideMode) { + return fmt.Errorf("secret %q cannot set checkout-locked environment variable %q while BUILDKITE_CHECKOUT_OVERRIDE_MODE=%s", pipelineSecret.Key, pipelineSecret.EnvironmentVariable, e.CheckoutOverrideMode) } var alreadySet bool diff --git a/internal/job/integration/hooks_integration_test.go b/internal/job/integration/hooks_integration_test.go index a8339651ff..ef316199fd 100644 --- a/internal/job/integration/hooks_integration_test.go +++ b/internal/job/integration/hooks_integration_test.go @@ -120,37 +120,39 @@ func TestHooksCanUnsetEnvironmentVariables(t *testing.T) { tester.RunAndCheck(t, "MY_CUSTOM_ENV=1") } -func TestEnvironmentHookNoCheckoutOverride(t *testing.T) { +func TestEnvironmentHookCheckoutOverrideMode(t *testing.T) { t.Parallel() + // An environment hook is a within-job source: the default (from-job) lets it + // set checkout vars; only strict blocks it. tests := []struct { name string envVar string envValue string - noCheckoutOverride bool + mode string // BUILDKITE_CHECKOUT_OVERRIDE_MODE; "" exercises the default wantEnv string wantBlockedWarning bool }{ { - name: "disabled_allows_skip_checkout", + name: "default_allows_skip_checkout", envVar: "BUILDKITE_SKIP_CHECKOUT", envValue: "true", wantEnv: "true", }, { - name: "enabled_blocks_skip_checkout", + name: "strict_blocks_skip_checkout", envVar: "BUILDKITE_SKIP_CHECKOUT", envValue: "true", - noCheckoutOverride: true, + mode: "strict", wantBlockedWarning: true, }, { // Sparse paths is only exercised in the blocked direction: the lock // strips it before checkout, so the real checkout is unaffected. - name: "enabled_blocks_sparse_checkout_paths", + name: "strict_blocks_sparse_checkout_paths", envVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", envValue: "a/b", - noCheckoutOverride: true, + mode: "strict", wantBlockedWarning: true, }, } @@ -192,8 +194,8 @@ func TestEnvironmentHookNoCheckoutOverride(t *testing.T) { }) env := []string{} - if tc.noCheckoutOverride { - env = append(env, "BUILDKITE_NO_CHECKOUT_OVERRIDE=true") + if tc.mode != "" { + env = append(env, "BUILDKITE_CHECKOUT_OVERRIDE_MODE="+tc.mode) } tester.RunAndCheck(t, env...) @@ -207,12 +209,13 @@ func TestEnvironmentHookNoCheckoutOverride(t *testing.T) { } } -func TestEnvironmentHookCannotDisableNoCheckoutOverride(t *testing.T) { +func TestEnvironmentHookCannotRelaxCheckoutOverrideMode(t *testing.T) { t.Parallel() - // A hook must not be able to turn the lock off mid-job: exporting - // BUILDKITE_NO_CHECKOUT_OVERRIDE=false should be ignored, so a checkout- - // scoped var the same hook tries to set stays blocked. + // A hook must not be able to relax the mode mid-job: exporting + // BUILDKITE_CHECKOUT_OVERRIDE_MODE=none should be ignored while the agent + // runs with strict, so a checkout-scoped var the same hook tries to set + // stays blocked. tester, err := NewExecutorTester(mainCtx) if err != nil { t.Fatalf("NewExecutorTester() error = %v", err) @@ -222,14 +225,14 @@ func TestEnvironmentHookCannotDisableNoCheckoutOverride(t *testing.T) { filename := "environment" script := []string{ "#!/usr/bin/env bash", - "export BUILDKITE_NO_CHECKOUT_OVERRIDE=false", + "export BUILDKITE_CHECKOUT_OVERRIDE_MODE=none", "export BUILDKITE_SKIP_CHECKOUT=true", } if runtime.GOOS == "windows" { filename = "environment.bat" script = []string{ "@echo off", - "set BUILDKITE_NO_CHECKOUT_OVERRIDE=false", + "set BUILDKITE_CHECKOUT_OVERRIDE_MODE=none", "set BUILDKITE_SKIP_CHECKOUT=true", } } @@ -240,31 +243,31 @@ func TestEnvironmentHookCannotDisableNoCheckoutOverride(t *testing.T) { tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { if got := c.GetEnv("BUILDKITE_SKIP_CHECKOUT"); got == "true" { - _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want the lock to stay on and block it\n", got) + _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want strict to stay on and block it\n", got) c.Exit(1) return } c.Exit(0) }) - tester.RunAndCheck(t, "BUILDKITE_NO_CHECKOUT_OVERRIDE=true") + tester.RunAndCheck(t, "BUILDKITE_CHECKOUT_OVERRIDE_MODE=strict") - // Both the lock var itself and the scoped var should be reported as blocked. - for _, want := range []string{"BUILDKITE_NO_CHECKOUT_OVERRIDE", "BUILDKITE_SKIP_CHECKOUT"} { + // Both the mode var itself and the scoped var should be reported as blocked. + for _, want := range []string{"BUILDKITE_CHECKOUT_OVERRIDE_MODE", "BUILDKITE_SKIP_CHECKOUT"} { if !strings.Contains(tester.Output, "env vars were blocked") || !strings.Contains(tester.Output, want) { t.Fatalf("output did not report %q as blocked\noutput: %s", want, tester.Output) } } } -func TestNoCommandEvalAutoLocksCheckoutVars(t *testing.T) { +func TestNoCommandEvalFloorsCheckoutOverrideModeToFromJob(t *testing.T) { t.Parallel() - // no-command-eval implies no-checkout-override: with command eval disabled - // the agent must lock checkout vars so git flags cannot be used to bypass - // the no-command-eval protection. The job never sets - // BUILDKITE_NO_CHECKOUT_OVERRIDE; the lock is derived purely from - // command-eval being off. + // Disabling command-eval floors the mode up to from-job (none -> from-job; + // from-job and strict are unchanged). from-job only locks outside-job + // sources (backend job env, secrets), so a trusted agent hook can still set + // checkout vars even with command-eval off and mode=none. The floor's + // effect on outside-job sources is covered by the secrets integration test. tester, err := NewExecutorTester(mainCtx) if err != nil { t.Fatalf("NewExecutorTester() error = %v", err) @@ -289,18 +292,18 @@ func TestNoCommandEvalAutoLocksCheckoutVars(t *testing.T) { } tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { - if got := c.GetEnv("BUILDKITE_SKIP_CHECKOUT"); got == "true" { - _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want the no-command-eval lock to block it\n", got) + if got := c.GetEnv("BUILDKITE_SKIP_CHECKOUT"); got != "true" { + _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want the hook's value to survive under from-job\n", got) c.Exit(1) return } c.Exit(0) }) - tester.RunAndCheck(t, "BUILDKITE_COMMAND_EVAL=false") + tester.RunAndCheck(t, "BUILDKITE_COMMAND_EVAL=false", "BUILDKITE_CHECKOUT_OVERRIDE_MODE=none") - if !strings.Contains(tester.Output, "env vars were blocked") || !strings.Contains(tester.Output, "BUILDKITE_SKIP_CHECKOUT") { - t.Fatalf("expected BUILDKITE_SKIP_CHECKOUT to be reported as blocked\noutput: %s", tester.Output) + if strings.Contains(tester.Output, "env vars were blocked") && strings.Contains(tester.Output, "BUILDKITE_SKIP_CHECKOUT") { + t.Fatalf("BUILDKITE_SKIP_CHECKOUT should not be blocked under from-job\noutput: %s", tester.Output) } } diff --git a/internal/job/integration/plugin_integration_test.go b/internal/job/integration/plugin_integration_test.go index c587422973..6911bef718 100644 --- a/internal/job/integration/plugin_integration_test.go +++ b/internal/job/integration/plugin_integration_test.go @@ -78,21 +78,21 @@ func TestRunningPlugins(t *testing.T) { tester.RunAndCheck(t, env...) } -func TestPluginEnvironmentHookNoCheckoutOverride(t *testing.T) { +func TestPluginEnvironmentHookCheckoutOverrideMode(t *testing.T) { t.Parallel() - // A plugin's environment hook (e.g. git-clean) may set checkout flags by - // default, but no-checkout-override must block that mutation. + // A plugin's environment hook (e.g. git-clean) is a within-job source: the + // default (from-job) lets it set checkout flags; only strict blocks it. tests := []struct { - name string - envVar string - envValue string - noCheckoutOverride bool - wantBlocked bool + name string + envVar string + envValue string + mode string // BUILDKITE_CHECKOUT_OVERRIDE_MODE; "" exercises the default + wantBlocked bool }{ - {name: "disabled_allows_plugin_to_override_skip_checkout", envVar: "BUILDKITE_SKIP_CHECKOUT", envValue: "true"}, - {name: "enabled_blocks_plugin_skip_checkout_override", envVar: "BUILDKITE_SKIP_CHECKOUT", envValue: "true", noCheckoutOverride: true, wantBlocked: true}, - {name: "enabled_blocks_plugin_sparse_checkout_paths_override", envVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", envValue: "a/b", noCheckoutOverride: true, wantBlocked: true}, + {name: "default_allows_plugin_to_override_skip_checkout", envVar: "BUILDKITE_SKIP_CHECKOUT", envValue: "true"}, + {name: "strict_blocks_plugin_skip_checkout_override", envVar: "BUILDKITE_SKIP_CHECKOUT", envValue: "true", mode: "strict", wantBlocked: true}, + {name: "strict_blocks_plugin_sparse_checkout_paths_override", envVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", envValue: "a/b", mode: "strict", wantBlocked: true}, } for _, tc := range tests { @@ -146,8 +146,8 @@ func TestPluginEnvironmentHookNoCheckoutOverride(t *testing.T) { }) env := []string{"BUILDKITE_PLUGINS=" + pluginJSON} - if tc.noCheckoutOverride { - env = append(env, "BUILDKITE_NO_CHECKOUT_OVERRIDE=true") + if tc.mode != "" { + env = append(env, "BUILDKITE_CHECKOUT_OVERRIDE_MODE="+tc.mode) } tester.RunAndCheck(t, env...) diff --git a/internal/job/integration/secrets_integration_test.go b/internal/job/integration/secrets_integration_test.go index 6d8f54b5b0..1e9cde7c38 100644 --- a/internal/job/integration/secrets_integration_test.go +++ b/internal/job/integration/secrets_integration_test.go @@ -564,21 +564,30 @@ func TestSecretsIntegration_ProtectedEnvironmentVariableRejection(t *testing.T) } } -func TestSecretsIntegration_NoCheckoutOverride(t *testing.T) { +func TestSecretsIntegration_CheckoutOverrideMode(t *testing.T) { t.Parallel() + // Secrets are an outside-the-job source, so both from-job (the default) and + // strict block a secret from setting a checkout-scoped var; only none allows + // it. tests := []struct { - name string - envVar string - secretValue string - noCheckoutOverride bool - wantErr bool + name string + envVar string + secretValue string + mode string // BUILDKITE_CHECKOUT_OVERRIDE_MODE; "" leaves the default + commandEvalDisabled bool + wantErr bool }{ - {name: "disabled_allows_checkout_scoped_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror"}, - {name: "enabled_rejects_checkout_locked_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", noCheckoutOverride: true, wantErr: true}, - {name: "enabled_rejects_sparse_checkout_paths_secret", envVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", secretValue: "a/b", noCheckoutOverride: true, wantErr: true}, + {name: "none_allows_checkout_scoped_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", mode: "none"}, + {name: "default_rejects_checkout_scoped_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", wantErr: true}, + {name: "from_job_rejects_checkout_scoped_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", mode: "from-job", wantErr: true}, + {name: "strict_rejects_checkout_locked_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", mode: "strict", wantErr: true}, + {name: "strict_rejects_sparse_checkout_paths_secret", envVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", secretValue: "a/b", mode: "strict", wantErr: true}, // The lock must not over-block: a non-checkout secret is still allowed. - {name: "enabled_allows_unscoped_secret", envVar: "MY_CUSTOM_VAR", secretValue: "hello", noCheckoutOverride: true}, + {name: "strict_allows_unscoped_secret", envVar: "MY_CUSTOM_VAR", secretValue: "hello", mode: "strict"}, + // Disabling command-eval floors none up to from-job, so an outside-job + // secret that none would allow is now rejected. + {name: "none_floored_to_from_job_rejects_secret_when_command_eval_disabled", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", mode: "none", commandEvalDisabled: true, wantErr: true}, } for _, tc := range tests { @@ -617,8 +626,11 @@ func TestSecretsIntegration_NoCheckoutOverride(t *testing.T) { fmt.Sprintf("BUILDKITE_SECRETS_CONFIG=%s", string(secretsJSON)), fmt.Sprintf("BUILDKITE_AGENT_ENDPOINT=%s", apiServer.URL), } - if tc.noCheckoutOverride { - env = append(env, "BUILDKITE_NO_CHECKOUT_OVERRIDE=true") + if tc.mode != "" { + env = append(env, "BUILDKITE_CHECKOUT_OVERRIDE_MODE="+tc.mode) + } + if tc.commandEvalDisabled { + env = append(env, "BUILDKITE_COMMAND_EVAL=false") } err = tester.Run(t, env...) diff --git a/jobapi/env.go b/jobapi/env.go index b6158c3044..b02cb430aa 100644 --- a/jobapi/env.go +++ b/jobapi/env.go @@ -144,7 +144,7 @@ func (s *Server) checkProtected(candidates []string) []string { // The Job API is only accessible from within the job, so allow writes // to vars that allow write from within job. if env.IsProtectedFromWithinJob(c) || - (s.noCheckoutOverride && env.IsCheckoutOverrideScoped(c)) { + env.IsCheckoutLockedFromWithinJob(c, s.checkoutOverrideMode) { protected = append(protected, c) } } @@ -152,16 +152,14 @@ func (s *Server) checkProtected(candidates []string) []string { } // protectedEnvError builds the rejection message for protected candidates, -// noting when the rejection is due to the no-checkout-override lock rather than -// an always-protected var. +// noting when the rejection is due to the checkout-override lock rather than an +// always-protected var. func (s *Server) protectedEnvError(protected []string) string { msg := fmt.Sprintf("the following environment variables are protected, and cannot be modified: % v", protected) - if s.noCheckoutOverride { - for _, p := range protected { - if env.IsCheckoutOverrideScoped(p) { - msg += ". Checkout-related variables are locked because BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled" - break - } + for _, p := range protected { + if env.IsCheckoutLockedFromWithinJob(p, s.checkoutOverrideMode) { + msg += fmt.Sprintf(". Checkout-related variables are locked because BUILDKITE_CHECKOUT_OVERRIDE_MODE=%s", s.checkoutOverrideMode) + break } } return msg diff --git a/jobapi/server.go b/jobapi/server.go index d6c34f6153..2d15b4d6c8 100644 --- a/jobapi/server.go +++ b/jobapi/server.go @@ -22,9 +22,9 @@ func WithDebug() ServerOpts { } } -func WithNoCheckoutOverride() ServerOpts { +func WithCheckoutOverrideMode(mode env.CheckoutOverrideMode) ServerOpts { return func(s *Server) { - s.noCheckoutOverride = true + s.checkoutOverrideMode = mode } } @@ -48,10 +48,10 @@ type PromiseFailureDeclarer func(ctx context.Context, exitStatus int, reason str // and allows jobs to introspect and mutate their own state type Server struct { // SocketPath is the path to the socket that the server is (or will be) listening on - SocketPath string - Logger shell.Logger - debug bool - noCheckoutOverride bool + SocketPath string + Logger shell.Logger + debug bool + checkoutOverrideMode env.CheckoutOverrideMode mtx sync.RWMutex environ *env.Environment diff --git a/jobapi/server_test.go b/jobapi/server_test.go index b16f559b73..90085bcea9 100644 --- a/jobapi/server_test.go +++ b/jobapi/server_test.go @@ -362,9 +362,11 @@ func TestPatchEnv(t *testing.T) { } } -func TestPatchEnvAllowsCheckoutScopedVarsWhenNoCheckoutOverrideDisabled(t *testing.T) { +func TestPatchEnvAllowsCheckoutScopedVarsUnderDefaultMode(t *testing.T) { t.Parallel() + // The Job API is a within-job source, so the default mode (from-job) lets it + // write checkout-scoped vars; only strict blocks it. environ := testEnvironWith("BUILDKITE_GIT_CLONE_FLAGS", "-v") srv, token, err := testServer(t, environ, replacer.NewMux()) if err != nil { @@ -399,11 +401,11 @@ func TestPatchEnvAllowsCheckoutScopedVarsWhenNoCheckoutOverrideDisabled(t *testi }) } -func TestPatchEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *testing.T) { +func TestPatchEnvRejectsCheckoutScopedVarsUnderStrict(t *testing.T) { t.Parallel() environ := testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false") - srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride()) + srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithCheckoutOverrideMode(env.CheckoutOverrideStrict)) if err != nil { t.Fatalf("creating server: %v", err) } @@ -433,17 +435,17 @@ func TestPatchEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *testi testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvUpdateRequestPayload, jobapi.EnvUpdateResponse]{ expectedStatus: http.StatusUnprocessableEntity, expectedError: &jobapi.ErrorResponse{ - Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_SKIP_CHECKOUT]. Checkout-related variables are locked because BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled", + Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_SKIP_CHECKOUT]. Checkout-related variables are locked because BUILDKITE_CHECKOUT_OVERRIDE_MODE=strict", }, expectedEnv: testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false").Dump(), }) } -func TestDeleteEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *testing.T) { +func TestDeleteEnvRejectsCheckoutScopedVarsUnderStrict(t *testing.T) { t.Parallel() environ := testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false") - srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride()) + srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithCheckoutOverrideMode(env.CheckoutOverrideStrict)) if err != nil { t.Fatalf("creating server: %v", err) } @@ -471,17 +473,17 @@ func TestDeleteEnvRejectsCheckoutScopedVarsWhenNoCheckoutOverrideEnabled(t *test testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvDeleteRequest, jobapi.EnvDeleteResponse]{ expectedStatus: http.StatusUnprocessableEntity, expectedError: &jobapi.ErrorResponse{ - Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_SKIP_CHECKOUT]. Checkout-related variables are locked because BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled", + Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_SKIP_CHECKOUT]. Checkout-related variables are locked because BUILDKITE_CHECKOUT_OVERRIDE_MODE=strict", }, expectedEnv: testEnvironWith("BUILDKITE_SKIP_CHECKOUT", "false").Dump(), }) } -func TestPatchEnvRejectsSparseCheckoutPathsWhenNoCheckoutOverrideEnabled(t *testing.T) { +func TestPatchEnvRejectsSparseCheckoutPathsUnderStrict(t *testing.T) { t.Parallel() environ := testEnvironWith("BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", "a/b") - srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride()) + srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithCheckoutOverrideMode(env.CheckoutOverrideStrict)) if err != nil { t.Fatalf("creating server: %v", err) } @@ -511,19 +513,19 @@ func TestPatchEnvRejectsSparseCheckoutPathsWhenNoCheckoutOverrideEnabled(t *test testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvUpdateRequestPayload, jobapi.EnvUpdateResponse]{ expectedStatus: http.StatusUnprocessableEntity, expectedError: &jobapi.ErrorResponse{ - Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS]. Checkout-related variables are locked because BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled", + Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS]. Checkout-related variables are locked because BUILDKITE_CHECKOUT_OVERRIDE_MODE=strict", }, expectedEnv: testEnvironWith("BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", "a/b").Dump(), }) } -func TestPatchEnvAllowsUnscopedVarsWhenNoCheckoutOverrideEnabled(t *testing.T) { +func TestPatchEnvAllowsUnscopedVarsUnderStrict(t *testing.T) { t.Parallel() // The lock must not over-block: a normal, non-checkout var stays writable - // while BUILDKITE_NO_CHECKOUT_OVERRIDE is enabled. + // even under strict. environ := testEnviron() - srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithNoCheckoutOverride()) + srv, token, err := testServer(t, environ, replacer.NewMux(), jobapi.WithCheckoutOverrideMode(env.CheckoutOverrideStrict)) if err != nil { t.Fatalf("creating server: %v", err) } From c7dd3a1822b547e2e99ac5a6fcbdaf3445c35419 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 14:07:04 -0400 Subject: [PATCH 32/57] Test sparse-checkout paths with empty agent config --- .../job_environment_integration_test.go | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index 2d4e1890ba..1f2e93a8b4 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -342,6 +342,34 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { wantEnvValue: "agent/path", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS"}, }, + // Empty agent config: sparse paths are exported unconditionally (unlike the + // main-era len()>0 guard), so cover the no-agent-paths edge. Unlocked must + // still defer to job env; locked must ignore it. The lock-on case exports an + // empty string where main left the var unset, which the checkout consumer + // treats identically (cleanGitSparseCheckoutPaths drops it). + { + name: "none_preserves_job_env_sparse_paths_with_empty_agent_config", + varName: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS": "job/path", + }, + agentCfg: agent.AgentConfiguration{ + CheckoutOverrideMode: env.CheckoutOverrideNone, + }, + wantEnvValue: "job/path", + }, + { + name: "strict_locks_sparse_paths_with_empty_agent_config", + varName: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS": "job/path", + }, + agentCfg: agent.AgentConfiguration{ + CheckoutOverrideMode: env.CheckoutOverrideStrict, + }, + wantEnvValue: "", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS"}, + }, // Inverse cases: when the agent config sits on the side that emits no var // by default, the lock must still force the agent value (regression for the // leak where backend job env survived while checkout override was locked). From 9d8ce12600596f4a4a9ac133290e83890319482e Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 14:24:29 -0400 Subject: [PATCH 33/57] Lock git-checkout-timeout under checkout-override --- .../job_environment_integration_test.go | 39 +++++++++++++++++++ agent/job_runner.go | 9 +++-- env/protected.go | 1 + env/protected_test.go | 1 + 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index 1f2e93a8b4..08606a6b24 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -552,6 +552,45 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { wantEnvValue: "false", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_MIRRORS_SKIP_UPDATE"}, }, + { + name: "none_allows_job_env_to_override_checkout_timeout", + varName: "BUILDKITE_GIT_CHECKOUT_TIMEOUT", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CHECKOUT_TIMEOUT": "99", + }, + agentCfg: agent.AgentConfiguration{ + GitCheckoutTimeout: 60, + CheckoutOverrideMode: env.CheckoutOverrideNone, + }, + wantEnvValue: "99", + }, + { + name: "strict_locks_checkout_timeout_to_agent_config", + varName: "BUILDKITE_GIT_CHECKOUT_TIMEOUT", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CHECKOUT_TIMEOUT": "99", + }, + agentCfg: agent.AgentConfiguration{ + GitCheckoutTimeout: 60, + CheckoutOverrideMode: env.CheckoutOverrideStrict, + }, + wantEnvValue: "60", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CHECKOUT_TIMEOUT"}, + }, + { + // Agent timeout at its default 0 is the silent side: the lock must + // still emit it so a job can't reintroduce a checkout timeout. + name: "strict_locks_checkout_timeout_off_to_agent_config", + varName: "BUILDKITE_GIT_CHECKOUT_TIMEOUT", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CHECKOUT_TIMEOUT": "99", + }, + agentCfg: agent.AgentConfiguration{ + CheckoutOverrideMode: env.CheckoutOverrideStrict, + }, + wantEnvValue: "0", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CHECKOUT_TIMEOUT"}, + }, } for _, tc := range tests { diff --git a/agent/job_runner.go b/agent/job_runner.go index 5665c32170..a8a4dbf920 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -624,6 +624,9 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setEnv("BUILDKITE_GIT_SUBMODULES", fmt.Sprint(r.conf.AgentConfiguration.GitSubmodules)) setEnv("BUILDKITE_SKIP_CHECKOUT", fmt.Sprint(r.conf.AgentConfiguration.SkipCheckout)) setEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", fmt.Sprint(r.conf.AgentConfiguration.GitSkipFetchExistingCommits)) + // A zero timeout means no checkout timeout; emit it anyway when locked so + // a job-supplied value can't reintroduce one past the agent config. + setEnv("BUILDKITE_GIT_CHECKOUT_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitCheckoutTimeout)) } else { // Default submodules off when disabled in agent config, but let pipeline/ // step env override via BUILDKITE_GIT_SUBMODULES. @@ -639,6 +642,9 @@ BUILDKITE_AGENT_JWKS_KEY_ID` if r.conf.AgentConfiguration.GitSkipFetchExistingCommits { setCheckoutEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", "true") } + if r.conf.AgentConfiguration.GitCheckoutTimeout > 0 { + setCheckoutEnv("BUILDKITE_GIT_CHECKOUT_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitCheckoutTimeout)) + } } setEnv("BUILDKITE_CHECKOUT_OVERRIDE_MODE", checkoutMode.String()) setEnv("BUILDKITE_CHECKOUT_ATTEMPTS", strconv.Itoa(r.conf.AgentConfiguration.CheckoutAttempts)) @@ -659,9 +665,6 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setEnv("BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", r.conf.AgentConfiguration.GitMirrorCheckoutMode) setCheckoutEnv("BUILDKITE_GIT_CLEAN_FLAGS", r.conf.AgentConfiguration.GitCleanFlags) setEnv("BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitMirrorsLockTimeout)) - if r.conf.AgentConfiguration.GitCheckoutTimeout > 0 { - setEnv("BUILDKITE_GIT_CHECKOUT_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitCheckoutTimeout)) - } setEnv("BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", strings.Join(r.conf.AgentConfiguration.GitSubmoduleCloneConfig, ",")) setEnv("BUILDKITE_GIT_COMMIT_VERIFICATION", r.conf.AgentConfiguration.GitCommitVerification) diff --git a/env/protected.go b/env/protected.go index 8a47d679b1..d1283e23ed 100644 --- a/env/protected.go +++ b/env/protected.go @@ -85,6 +85,7 @@ var protectedEnv = map[string]protection{ // are disjoint. var checkoutOverrideScope = map[string]struct{}{ "BUILDKITE_GIT_CHECKOUT_FLAGS": {}, + "BUILDKITE_GIT_CHECKOUT_TIMEOUT": {}, "BUILDKITE_GIT_CLEAN_FLAGS": {}, "BUILDKITE_GIT_CLONE_FLAGS": {}, "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": {}, diff --git a/env/protected_test.go b/env/protected_test.go index 34a8008033..900d3999f9 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -75,6 +75,7 @@ func TestCheckoutOverrideScope(t *testing.T) { scoped := []string{ "BUILDKITE_GIT_CHECKOUT_FLAGS", + "BUILDKITE_GIT_CHECKOUT_TIMEOUT", "BUILDKITE_GIT_CLONE_FLAGS", "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", "BUILDKITE_GIT_CLEAN_FLAGS", From cdf40c9fdd85a4b157e395b9699c4cbfe5406b94 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 15:38:37 -0400 Subject: [PATCH 34/57] Fix checkoutOverrideScope doc comment --- env/protected.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/env/protected.go b/env/protected.go index d1283e23ed..5209293fa9 100644 --- a/env/protected.go +++ b/env/protected.go @@ -76,13 +76,14 @@ var protectedEnv = map[string]protection{ "BUILDKITE_SSH_KEYSCAN": {}, } -// checkoutOverrideScope contains checkout-related vars that remain mutable in -// hooks, plugins, Job API, and secrets by default so jobs can tailor checkout -// behavior. When checkout override is enabled, those same vars become locked so -// agent checkout config wins: git is riddled with shell injections, so letting a -// job set git flags would otherwise be a way to bypass protections like -// no-command-eval. Vars here must not also appear in protectedEnv; the two maps -// are disjoint. +// checkoutOverrideScope contains checkout-related vars whose write-protection +// depends on the checkout-override mode (see CheckoutOverrideMode). Under the +// default (from-job) they're locked against backend job env and secrets but +// still mutable from hooks, plugins, and the Job API; strict locks them against +// every source; none leaves them open. Locking matters because git is riddled +// with shell injections, so letting a job set git flags would otherwise be a way +// to bypass protections like no-command-eval. Vars here must not also appear in +// protectedEnv; the two maps are disjoint. var checkoutOverrideScope = map[string]struct{}{ "BUILDKITE_GIT_CHECKOUT_FLAGS": {}, "BUILDKITE_GIT_CHECKOUT_TIMEOUT": {}, From eb6148a7c11708bc749a241978cf092ab257c8e1 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 15:38:37 -0400 Subject: [PATCH 35/57] Rename protectedEnvError to protectedEnvMessage --- jobapi/env.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/jobapi/env.go b/jobapi/env.go index b02cb430aa..5075b83688 100644 --- a/jobapi/env.go +++ b/jobapi/env.go @@ -41,7 +41,7 @@ func (s *Server) patchEnv(w http.ResponseWriter, r *http.Request) { if len(protected) > 0 { err := socket.WriteError( w, - s.protectedEnvError(protected), + s.protectedEnvMessage(protected), http.StatusUnprocessableEntity, ) if err != nil { @@ -110,7 +110,7 @@ func (s *Server) deleteEnv(w http.ResponseWriter, r *http.Request) { if len(protected) > 0 { err := socket.WriteError( w, - s.protectedEnvError(protected), + s.protectedEnvMessage(protected), http.StatusUnprocessableEntity, ) if err != nil { @@ -151,10 +151,10 @@ func (s *Server) checkProtected(candidates []string) []string { return protected } -// protectedEnvError builds the rejection message for protected candidates, +// protectedEnvMessage builds the rejection message for protected candidates, // noting when the rejection is due to the checkout-override lock rather than an // always-protected var. -func (s *Server) protectedEnvError(protected []string) string { +func (s *Server) protectedEnvMessage(protected []string) string { msg := fmt.Sprintf("the following environment variables are protected, and cannot be modified: % v", protected) for _, p := range protected { if env.IsCheckoutLockedFromWithinJob(p, s.checkoutOverrideMode) { From 609c8bfd5dcbb69989d6171df5299cac274147c8 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 15:38:37 -0400 Subject: [PATCH 36/57] Test DeleteEnv allows checkout vars when unlocked --- jobapi/server_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/jobapi/server_test.go b/jobapi/server_test.go index 90085bcea9..561b343c98 100644 --- a/jobapi/server_test.go +++ b/jobapi/server_test.go @@ -479,6 +479,44 @@ func TestDeleteEnvRejectsCheckoutScopedVarsUnderStrict(t *testing.T) { }) } +func TestDeleteEnvAllowsCheckoutScopedVarsUnderDefaultMode(t *testing.T) { + t.Parallel() + + // The Job API is a within-job source, so the default mode (from-job) lets it + // delete checkout-scoped vars; only strict blocks it. + environ := testEnvironWith("BUILDKITE_GIT_CLONE_FLAGS", "-v") + srv, token, err := testServer(t, environ, replacer.NewMux()) + if err != nil { + t.Fatalf("creating server: %v", err) + } + + if err := srv.Start(); err != nil { + t.Fatalf("starting server: %v", err) + } + defer func() { + if err := srv.Stop(); err != nil { + t.Fatalf("stopping server: %v", err) + } + }() + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(&jobapi.EnvDeleteRequest{Keys: []string{"BUILDKITE_GIT_CLONE_FLAGS"}}); err != nil { + t.Fatalf("encoding request body: %v", err) + } + + req, err := http.NewRequest(http.MethodDelete, "http://job/api/current-job/v0/env", buf) + if err != nil { + t.Fatalf("creating request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + testAPI(t, environ, req, testSocketClient(srv.SocketPath), apiTestCase[jobapi.EnvDeleteRequest, jobapi.EnvDeleteResponse]{ + expectedStatus: http.StatusOK, + expectedResponseBody: &jobapi.EnvDeleteResponse{Deleted: []string{"BUILDKITE_GIT_CLONE_FLAGS"}}, + expectedEnv: testEnviron().Dump(), + }) +} + func TestPatchEnvRejectsSparseCheckoutPathsUnderStrict(t *testing.T) { t.Parallel() From 39cd90f582122e27dbc89a47808e9de366f96320 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 15:38:37 -0400 Subject: [PATCH 37/57] Test default checkout-override mode locks job env --- .../job_environment_integration_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index 08606a6b24..74750f0a3d 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -267,6 +267,21 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { wantEnvValue: "--mirror", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_FLAGS"}, }, + { + // The zero value (unset CheckoutOverrideMode) is the default, from-job. + // Pin that the default blocks backend job env, so a future default flip + // or predicate change can't silently weaken it (regression for agent-7bu). + name: "default_locks_clone_flags_to_agent_config", + varName: "BUILDKITE_GIT_CLONE_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CLONE_FLAGS": "--no-tags", + }, + agentCfg: agent.AgentConfiguration{ + GitCloneFlags: "--mirror", + }, + wantEnvValue: "--mirror", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_FLAGS"}, + }, { name: "none_allows_job_env_to_enable_submodules", varName: "BUILDKITE_GIT_SUBMODULES", From a5a73924a31129629627e53367705c73f4482015 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 16:52:33 -0400 Subject: [PATCH 38/57] Fix misleading comment in createEnvironment The env package is imported as envutil in this file, so the local env map does not shadow it; the shadowing rationale was inaccurate. --- agent/job_runner.go | 1 - 1 file changed, 1 deletion(-) diff --git a/agent/job_runner.go b/agent/job_runner.go index a8a4dbf920..1809ed58a0 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -405,7 +405,6 @@ func (r *JobRunner) normalizeVerificationBehavior(behavior string) (string, erro // Creates the environment variables that will be used in the process and writes a flat environment file func (r *JobRunner) createEnvironment(ctx context.Context) ([]string, error) { - // Captured before the local env map below shadows the env package name. // Checkout-scoped vars are locked against backend job env under strict and // from-job (i.e. every mode except none). checkoutMode := r.conf.AgentConfiguration.CheckoutOverrideMode From 5bcb40d5cbae3d19101ee4e21140dc4d4f81731c Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 16:52:33 -0400 Subject: [PATCH 39/57] Extract shared checkout-override-mode resolver Both config methods duplicated the parse-and-floor logic behind a manual keep-in-sync comment. Fold it into resolveCheckoutOverrideMode so the command-eval polarity is the only per-config difference. --- clicommand/agent_start.go | 13 +++---------- clicommand/bootstrap.go | 13 +++---------- clicommand/global.go | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index 1f92a0f60d..2baa989a6f 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -234,17 +234,10 @@ type AgentStartConfig struct { DisconnectAfterJobTimeout int `cli:"disconnect-after-job-timeout" deprecated:"Use disconnect-after-idle-timeout instead"` } -// checkoutOverrideMode parses the configured checkout-override mode and floors -// it at from-job when command-eval is disabled, so a job can't use backend env -// or secret git flags to bypass no-command-eval. AgentStartConfig stores -// NoCommandEval; the BootstrapConfig sibling uses CommandEval, so its check is -// inverted. Keep the two in sync. +// checkoutOverrideMode resolves the configured mode, flooring it for command-eval. +// AgentStartConfig stores NoCommandEval (the BootstrapConfig sibling stores CommandEval). func (cfg *AgentStartConfig) checkoutOverrideMode() (env.CheckoutOverrideMode, error) { - mode, err := env.ParseCheckoutOverrideMode(cfg.CheckoutOverrideMode) - if err != nil { - return mode, err - } - return mode.FlooredForCommandEval(!cfg.NoCommandEval), nil + return resolveCheckoutOverrideMode(cfg.CheckoutOverrideMode, !cfg.NoCommandEval) } func (asc AgentStartConfig) Features(ctx context.Context) []string { diff --git a/clicommand/bootstrap.go b/clicommand/bootstrap.go index d0de9f563b..f46c1f6bf5 100644 --- a/clicommand/bootstrap.go +++ b/clicommand/bootstrap.go @@ -123,17 +123,10 @@ type BootstrapConfig struct { CheckoutAttempts int `cli:"checkout-attempts"` } -// checkoutOverrideMode parses the configured checkout-override mode and floors -// it at from-job when command-eval is disabled, so a job can't use backend env -// or secret git flags to bypass no-command-eval. BootstrapConfig stores -// CommandEval; the AgentStartConfig sibling uses NoCommandEval, so its check is -// inverted. Keep the two in sync. +// checkoutOverrideMode resolves the configured mode, flooring it for command-eval. +// BootstrapConfig stores CommandEval (the AgentStartConfig sibling stores NoCommandEval). func (cfg *BootstrapConfig) checkoutOverrideMode() (env.CheckoutOverrideMode, error) { - mode, err := env.ParseCheckoutOverrideMode(cfg.CheckoutOverrideMode) - if err != nil { - return mode, err - } - return mode.FlooredForCommandEval(cfg.CommandEval), nil + return resolveCheckoutOverrideMode(cfg.CheckoutOverrideMode, cfg.CommandEval) } var BootstrapCommand = cli.Command{ diff --git a/clicommand/global.go b/clicommand/global.go index 907191d002..9deb9fff33 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -11,6 +11,7 @@ import ( "github.com/buildkite/agent/v3/api" "github.com/buildkite/agent/v3/cliconfig" + "github.com/buildkite/agent/v3/env" "github.com/buildkite/agent/v3/internal/experiments" "github.com/buildkite/agent/v3/internal/job" "github.com/buildkite/agent/v3/logger" @@ -340,6 +341,19 @@ type APIConfig struct { NoHTTP2 bool `cli:"no-http2"` } +// resolveCheckoutOverrideMode parses a checkout-override mode value and floors it +// at from-job when command-eval is disabled, so a job can't use backend env or +// secret git flags to bypass no-command-eval. AgentStartConfig and BootstrapConfig +// store the command-eval flag with opposite polarity, so each passes the resolved +// boolean here. +func resolveCheckoutOverrideMode(raw string, commandEvalEnabled bool) (env.CheckoutOverrideMode, error) { + mode, err := env.ParseCheckoutOverrideMode(raw) + if err != nil { + return mode, err + } + return mode.FlooredForCommandEval(commandEvalEnabled), nil +} + func globalFlags() []cli.Flag { return []cli.Flag{ NoColorFlag, From 1de35ca4dace58aa78d271ffdb3b818c80486eb7 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Tue, 14 Jul 2026 16:52:33 -0400 Subject: [PATCH 40/57] Test none mode allows skip-fetch job env override Give BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS a none-mode case so all checkout-scoped vars are covered in both directions. --- .../integration/job_environment_integration_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index 74750f0a3d..b00b4ff961 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -439,6 +439,18 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { wantEnvValue: "false", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS"}, }, + { + name: "none_allows_job_env_to_override_skip_fetch_existing_commits", + varName: "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": "false", + }, + agentCfg: agent.AgentConfiguration{ + GitSkipFetchExistingCommits: true, + CheckoutOverrideMode: env.CheckoutOverrideNone, + }, + wantEnvValue: "false", + }, // The remaining checkout-scoped vars all flow through setCheckoutEnv; cover // each in both directions so a regression in any one is caught. The git flag // vars are the injection vectors the lock exists to contain. From c4dd5653a15c37432ab1f0581b0c64e1c9d5dbac Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 09:41:00 -0400 Subject: [PATCH 41/57] Let the job drive checkout unless mode is strict Model A from the PR #4010 review. The default (from-job) now lets the job's own sources (pipeline/step env, hooks, plugins, Job API) override the agent's checkout config, giving pipelines full per-pipeline control of checkout options; only strict keeps the agent authoritative, and secrets stay blocked outside none. Disabling command-eval floors the mode to strict so no source can inject git flags to bypass no-command-eval. Rename the lock predicates to match: IsCheckoutLocked now governs the job's own config sources (backend env plus hooks/plugins/Job API), and IsCheckoutLockedForSecrets governs secret-to-env mappings. --- .../job_environment_integration_test.go | 74 +++++++++++++++++-- agent/job_runner.go | 6 +- clicommand/agent_start_test.go | 11 ++- clicommand/global.go | 10 +-- env/protected.go | 65 ++++++++-------- env/protected_test.go | 52 +++++++++---- internal/job/config.go | 7 +- internal/job/executor.go | 4 +- .../job/integration/hooks_integration_test.go | 19 +++-- .../integration/secrets_integration_test.go | 6 +- jobapi/env.go | 4 +- 11 files changed, 177 insertions(+), 81 deletions(-) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index b00b4ff961..593ed1f2b2 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -269,9 +269,9 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { }, { // The zero value (unset CheckoutOverrideMode) is the default, from-job. - // Pin that the default blocks backend job env, so a future default flip - // or predicate change can't silently weaken it (regression for agent-7bu). - name: "default_locks_clone_flags_to_agent_config", + // Pin that the default lets pipeline/step env override agent checkout + // config, so a future predicate change can't silently lock it back down. + name: "default_allows_job_env_to_override_clone_flags", varName: "BUILDKITE_GIT_CLONE_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CLONE_FLAGS": "--no-tags", @@ -279,8 +279,72 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { agentCfg: agent.AgentConfiguration{ GitCloneFlags: "--mirror", }, - wantEnvValue: "--mirror", - wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_FLAGS"}, + wantEnvValue: "--no-tags", + }, + { + // Same as above but with the mode named explicitly, pinning that from-job + // (not just the zero value) allows the pipeline/step env override. + name: "from_job_allows_job_env_to_override_clone_flags", + varName: "BUILDKITE_GIT_CLONE_FLAGS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CLONE_FLAGS": "--no-tags", + }, + agentCfg: agent.AgentConfiguration{ + GitCloneFlags: "--mirror", + CheckoutOverrideMode: env.CheckoutOverrideFromJob, + }, + wantEnvValue: "--no-tags", + }, + // The four vars below use the conditional-emit branch in createEnvironment + // (a separate code path from setCheckoutEnv). Pin that from-job takes the + // deferring branch for them, so job env wins, matching none. + { + name: "from_job_allows_job_env_to_enable_submodules", + varName: "BUILDKITE_GIT_SUBMODULES", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SUBMODULES": "true", + }, + agentCfg: agent.AgentConfiguration{ + GitSubmodules: false, + CheckoutOverrideMode: env.CheckoutOverrideFromJob, + }, + wantEnvValue: "true", + }, + { + name: "from_job_allows_job_env_to_override_skip_checkout", + varName: "BUILDKITE_SKIP_CHECKOUT", + jobEnv: map[string]string{ + "BUILDKITE_SKIP_CHECKOUT": "false", + }, + agentCfg: agent.AgentConfiguration{ + SkipCheckout: true, + CheckoutOverrideMode: env.CheckoutOverrideFromJob, + }, + wantEnvValue: "false", + }, + { + name: "from_job_allows_job_env_to_override_skip_fetch_existing_commits", + varName: "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": "false", + }, + agentCfg: agent.AgentConfiguration{ + GitSkipFetchExistingCommits: true, + CheckoutOverrideMode: env.CheckoutOverrideFromJob, + }, + wantEnvValue: "false", + }, + { + name: "from_job_allows_job_env_to_override_checkout_timeout", + varName: "BUILDKITE_GIT_CHECKOUT_TIMEOUT", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CHECKOUT_TIMEOUT": "99", + }, + agentCfg: agent.AgentConfiguration{ + GitCheckoutTimeout: 60, + CheckoutOverrideMode: env.CheckoutOverrideFromJob, + }, + wantEnvValue: "99", }, { name: "none_allows_job_env_to_enable_submodules", diff --git a/agent/job_runner.go b/agent/job_runner.go index 1809ed58a0..683edc2fc1 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -405,10 +405,10 @@ func (r *JobRunner) normalizeVerificationBehavior(behavior string) (string, erro // Creates the environment variables that will be used in the process and writes a flat environment file func (r *JobRunner) createEnvironment(ctx context.Context) ([]string, error) { - // Checkout-scoped vars are locked against backend job env under strict and - // from-job (i.e. every mode except none). + // Checkout-scoped vars are locked against backend job env only under strict. + // from-job (the default) and none let pipeline/step env override agent config. checkoutMode := r.conf.AgentConfiguration.CheckoutOverrideMode - checkoutLockedFromJobEnv := checkoutMode != envutil.CheckoutOverrideNone + checkoutLockedFromJobEnv := checkoutMode == envutil.CheckoutOverrideStrict // Create a clone of our jobs environment. We'll then set the // environment variables provided by the agent, which will override any diff --git a/clicommand/agent_start_test.go b/clicommand/agent_start_test.go index 2265f2b95a..21dd6852c0 100644 --- a/clicommand/agent_start_test.go +++ b/clicommand/agent_start_test.go @@ -381,7 +381,7 @@ func TestAgentStartCheckoutOverrideMode(t *testing.T) { t.Parallel() // AgentStartConfig uses NoCommandEval, so the zero value leaves command-eval - // enabled; none is only floored to from-job when command-eval is disabled. + // enabled; disabling command-eval floors every mode to strict. tests := []struct { name string cfg AgentStartConfig @@ -390,7 +390,8 @@ func TestAgentStartCheckoutOverrideMode(t *testing.T) { {name: "default_from_job", cfg: AgentStartConfig{}, want: env.CheckoutOverrideFromJob}, {name: "explicit_strict", cfg: AgentStartConfig{CheckoutOverrideMode: "strict"}, want: env.CheckoutOverrideStrict}, {name: "explicit_none", cfg: AgentStartConfig{CheckoutOverrideMode: "none"}, want: env.CheckoutOverrideNone}, - {name: "no_command_eval_floors_none_to_from_job", cfg: AgentStartConfig{CheckoutOverrideMode: "none", NoCommandEval: true}, want: env.CheckoutOverrideFromJob}, + {name: "no_command_eval_floors_none_to_strict", cfg: AgentStartConfig{CheckoutOverrideMode: "none", NoCommandEval: true}, want: env.CheckoutOverrideStrict}, + {name: "no_command_eval_floors_from_job_to_strict", cfg: AgentStartConfig{CheckoutOverrideMode: "from-job", NoCommandEval: true}, want: env.CheckoutOverrideStrict}, {name: "no_command_eval_leaves_strict", cfg: AgentStartConfig{CheckoutOverrideMode: "strict", NoCommandEval: true}, want: env.CheckoutOverrideStrict}, } @@ -420,7 +421,8 @@ func TestBootstrapCheckoutOverrideMode(t *testing.T) { t.Parallel() // BootstrapConfig uses CommandEval; the zero value disables command-eval and - // floors none to from-job, so cases that keep none must set CommandEval. + // floors the mode to strict, so cases that keep a laxer mode must set + // CommandEval. tests := []struct { name string cfg BootstrapConfig @@ -429,7 +431,8 @@ func TestBootstrapCheckoutOverrideMode(t *testing.T) { {name: "default_from_job", cfg: BootstrapConfig{CommandEval: true}, want: env.CheckoutOverrideFromJob}, {name: "explicit_strict", cfg: BootstrapConfig{CheckoutOverrideMode: "strict", CommandEval: true}, want: env.CheckoutOverrideStrict}, {name: "explicit_none", cfg: BootstrapConfig{CheckoutOverrideMode: "none", CommandEval: true}, want: env.CheckoutOverrideNone}, - {name: "command_eval_disabled_floors_none_to_from_job", cfg: BootstrapConfig{CheckoutOverrideMode: "none", CommandEval: false}, want: env.CheckoutOverrideFromJob}, + {name: "command_eval_disabled_floors_none_to_strict", cfg: BootstrapConfig{CheckoutOverrideMode: "none", CommandEval: false}, want: env.CheckoutOverrideStrict}, + {name: "command_eval_disabled_floors_from_job_to_strict", cfg: BootstrapConfig{CheckoutOverrideMode: "from-job", CommandEval: false}, want: env.CheckoutOverrideStrict}, } for _, tc := range tests { diff --git a/clicommand/global.go b/clicommand/global.go index 9deb9fff33..4a293f1c98 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -209,7 +209,7 @@ var ( CheckoutOverrideModeFlag = cli.StringFlag{ Name: "checkout-override-mode", Value: "from-job", - Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) blocks pipeline/step env and secrets but lets hooks, plugins, and the Job API set checkout vars. ′none′ lets any source override. Disabling command-eval floors this at ′from-job′.", checkoutOverrideModes), + Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ lets any source, including secrets, override. Disabling command-eval floors this at ′strict′.", checkoutOverrideModes), EnvVar: "BUILDKITE_CHECKOUT_OVERRIDE_MODE", } @@ -342,10 +342,10 @@ type APIConfig struct { } // resolveCheckoutOverrideMode parses a checkout-override mode value and floors it -// at from-job when command-eval is disabled, so a job can't use backend env or -// secret git flags to bypass no-command-eval. AgentStartConfig and BootstrapConfig -// store the command-eval flag with opposite polarity, so each passes the resolved -// boolean here. +// at strict when command-eval is disabled, so a job can't use pipeline/step env +// or secret git flags to bypass no-command-eval. AgentStartConfig and +// BootstrapConfig store the command-eval flag with opposite polarity, so each +// passes the resolved boolean here. func resolveCheckoutOverrideMode(raw string, commandEvalEnabled bool) (env.CheckoutOverrideMode, error) { mode, err := env.ParseCheckoutOverrideMode(raw) if err != nil { diff --git a/env/protected.go b/env/protected.go index 5209293fa9..9818805ad3 100644 --- a/env/protected.go +++ b/env/protected.go @@ -78,12 +78,14 @@ var protectedEnv = map[string]protection{ // checkoutOverrideScope contains checkout-related vars whose write-protection // depends on the checkout-override mode (see CheckoutOverrideMode). Under the -// default (from-job) they're locked against backend job env and secrets but -// still mutable from hooks, plugins, and the Job API; strict locks them against -// every source; none leaves them open. Locking matters because git is riddled -// with shell injections, so letting a job set git flags would otherwise be a way -// to bypass protections like no-command-eval. Vars here must not also appear in -// protectedEnv; the two maps are disjoint. +// default (from-job) the job may set them from pipeline/step env, hooks, plugins, +// and the Job API, overriding agent config, but secrets may not; strict locks +// them against every source; none leaves them fully open, including secrets. +// Locking matters because git is riddled with shell injections, so letting a job +// set git flags would otherwise be a way to bypass protections like +// no-command-eval (which is why disabling command-eval floors the mode at +// strict). Vars here must not also appear in protectedEnv; the two maps are +// disjoint. var checkoutOverrideScope = map[string]struct{}{ "BUILDKITE_GIT_CHECKOUT_FLAGS": {}, "BUILDKITE_GIT_CHECKOUT_TIMEOUT": {}, @@ -119,7 +121,7 @@ func IsProtectedFromWithinJob(name string) bool { // IsCheckoutOverrideScoped reports whether the environment variable is a // checkout-related var whose write-protection depends on the checkout-override // mode. Whether it's actually locked for a given source is decided by -// IsCheckoutLocked and IsCheckoutLockedFromWithinJob. +// IsCheckoutLocked (the job's own config sources) and IsCheckoutLockedForSecrets. func IsCheckoutOverrideScoped(name string) bool { _, exists := checkoutOverrideScope[normalizeKeyName(name)] return exists @@ -131,17 +133,19 @@ func IsCheckoutOverrideScoped(name string) bool { type CheckoutOverrideMode int const ( - // CheckoutOverrideFromJob is the default and matches the agent's historical - // behaviour: checkout-scoped vars are locked against backend job env and - // secrets, but hooks, plugins, and the Job API may still set them so a job - // can tailor its own checkout. + // CheckoutOverrideFromJob is the default: the job may configure its own + // checkout from pipeline/step env, hooks, plugins, and the Job API, + // overriding agent config, but secrets may not set checkout-scoped vars. This + // is deliberately more permissive than the agent's historical behaviour, + // which locked checkout-scoped vars against backend job env. CheckoutOverrideFromJob CheckoutOverrideMode = iota // CheckoutOverrideStrict makes agent checkout config authoritative against - // every source: backend job env, secrets, hooks, plugins, and the Job API. + // every source: pipeline/step env, secrets, hooks, plugins, and the Job API. CheckoutOverrideStrict - // CheckoutOverrideNone lets any source override agent checkout config. + // CheckoutOverrideNone lets any source, including secrets, override agent + // checkout config. CheckoutOverrideNone ) @@ -186,34 +190,37 @@ func ParseCheckoutOverrideMode(s string) (CheckoutOverrideMode, error) { } // FlooredForCommandEval restricts the mode so command-eval can't be bypassed: -// when command-eval is disabled, CheckoutOverrideNone is raised to -// CheckoutOverrideFromJob, which still blocks backend job env and secret git -// flags (the injection vector) while leaving hooks and plugins free to tailor -// checkout. +// when command-eval is disabled, the mode is raised to CheckoutOverrideStrict so +// no source (pipeline/step env, secrets, hooks, plugins, or the Job API) can +// inject git flags that would otherwise circumvent no-command-eval. func (m CheckoutOverrideMode) FlooredForCommandEval(commandEvalEnabled bool) CheckoutOverrideMode { - if !commandEvalEnabled && m == CheckoutOverrideNone { - return CheckoutOverrideFromJob + if !commandEvalEnabled { + return CheckoutOverrideStrict } return m } -// IsCheckoutLocked reports whether a checkout-scoped var is locked against writes -// from outside the running job (backend job env and secrets) under the given -// mode. Vars that aren't checkout-scoped are governed by IsProtected instead. +// IsCheckoutLocked reports whether a checkout-scoped var is locked against the +// job's own configuration sources (backend job env, hooks, plugins, and the Job +// API) under the given mode. Only strict locks these; from-job and none let the +// job configure its own checkout. Secrets are governed separately by +// IsCheckoutLockedForSecrets, and vars that aren't checkout-scoped by IsProtected +// / IsProtectedFromWithinJob. func IsCheckoutLocked(name string, mode CheckoutOverrideMode) bool { if !IsCheckoutOverrideScoped(name) { return false } - return mode == CheckoutOverrideStrict || mode == CheckoutOverrideFromJob + return mode == CheckoutOverrideStrict } -// IsCheckoutLockedFromWithinJob reports whether a checkout-scoped var is locked -// against writes from within the running job (hooks, plugins, and the Job API) -// under the given mode. Vars that aren't checkout-scoped are governed by -// IsProtectedFromWithinJob instead. -func IsCheckoutLockedFromWithinJob(name string, mode CheckoutOverrideMode) bool { +// IsCheckoutLockedForSecrets reports whether a checkout-scoped var is locked +// against secret-to-env mappings under the given mode. Secrets are an external +// source, so both strict and from-job block them; only none lets a secret set +// checkout config. Vars that aren't checkout-scoped are governed by IsProtected +// instead. +func IsCheckoutLockedForSecrets(name string, mode CheckoutOverrideMode) bool { if !IsCheckoutOverrideScoped(name) { return false } - return mode == CheckoutOverrideStrict + return mode != CheckoutOverrideNone } diff --git a/env/protected_test.go b/env/protected_test.go index 900d3999f9..68ac8fdd1b 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -175,26 +175,25 @@ func TestCheckoutLockPredicates(t *testing.T) { unscoped = "MY_CUSTOM_VAR" // in neither map ) - // The matrix from the design: strict locks every source; from-job locks the - // outside-job sources (backend env, secrets) but leaves within-job sources - // (hooks, plugins, Job API) open, matching the agent's historical behaviour; - // none locks nothing. + // The matrix from the design: strict locks every source; from-job lets the + // job's own sources (backend env, hooks, plugins, Job API) set checkout vars + // but still blocks secrets; none locks nothing, including secrets. cases := []struct { - mode CheckoutOverrideMode - wantFromJobEnv bool - wantWithinJob bool + mode CheckoutOverrideMode + wantJob bool + wantSecrets bool }{ {CheckoutOverrideStrict, true, true}, - {CheckoutOverrideFromJob, true, false}, + {CheckoutOverrideFromJob, false, true}, {CheckoutOverrideNone, false, false}, } for _, tc := range cases { - if got := IsCheckoutLocked(scoped, tc.mode); got != tc.wantFromJobEnv { - t.Errorf("IsCheckoutLocked(%q, %v) = %t, want %t", scoped, tc.mode, got, tc.wantFromJobEnv) + if got := IsCheckoutLocked(scoped, tc.mode); got != tc.wantJob { + t.Errorf("IsCheckoutLocked(%q, %v) = %t, want %t", scoped, tc.mode, got, tc.wantJob) } - if got := IsCheckoutLockedFromWithinJob(scoped, tc.mode); got != tc.wantWithinJob { - t.Errorf("IsCheckoutLockedFromWithinJob(%q, %v) = %t, want %t", scoped, tc.mode, got, tc.wantWithinJob) + if got := IsCheckoutLockedForSecrets(scoped, tc.mode); got != tc.wantSecrets { + t.Errorf("IsCheckoutLockedForSecrets(%q, %v) = %t, want %t", scoped, tc.mode, got, tc.wantSecrets) } // Non-checkout-scoped vars are never governed by the checkout predicates, @@ -203,13 +202,38 @@ func TestCheckoutLockPredicates(t *testing.T) { if IsCheckoutLocked(name, tc.mode) { t.Errorf("IsCheckoutLocked(%q, %v) = true, want false", name, tc.mode) } - if IsCheckoutLockedFromWithinJob(name, tc.mode) { - t.Errorf("IsCheckoutLockedFromWithinJob(%q, %v) = true, want false", name, tc.mode) + if IsCheckoutLockedForSecrets(name, tc.mode) { + t.Errorf("IsCheckoutLockedForSecrets(%q, %v) = true, want false", name, tc.mode) } } } } +func TestFlooredForCommandEval(t *testing.T) { + t.Parallel() + + // With command-eval enabled the mode is unchanged; disabling it raises every + // mode to strict so no source can inject git flags to bypass no-command-eval. + cases := []struct { + mode CheckoutOverrideMode + commandEvalOn CheckoutOverrideMode + commandEvalOffMax CheckoutOverrideMode + }{ + {CheckoutOverrideStrict, CheckoutOverrideStrict, CheckoutOverrideStrict}, + {CheckoutOverrideFromJob, CheckoutOverrideFromJob, CheckoutOverrideStrict}, + {CheckoutOverrideNone, CheckoutOverrideNone, CheckoutOverrideStrict}, + } + + for _, tc := range cases { + if got := tc.mode.FlooredForCommandEval(true); got != tc.commandEvalOn { + t.Errorf("%v.FlooredForCommandEval(true) = %v, want %v", tc.mode, got, tc.commandEvalOn) + } + if got := tc.mode.FlooredForCommandEval(false); got != tc.commandEvalOffMax { + t.Errorf("%v.FlooredForCommandEval(false) = %v, want %v", tc.mode, got, tc.commandEvalOffMax) + } + } +} + func TestProtectedAndCheckoutScopeDisjoint(t *testing.T) { t.Parallel() diff --git a/internal/job/config.go b/internal/job/config.go index 699a51e97a..15eac7bdea 100644 --- a/internal/job/config.go +++ b/internal/job/config.go @@ -252,10 +252,9 @@ func (c *ExecutorConfig) ReadFromEnvironment(environ *env.Environment) map[strin // Find struct fields with env tag if tag := f.Tag.Get("env"); tag != "" && environ.Exists(tag) { // ReadFromEnvironment runs after applyEnvironmentChanges, so the - // checkout vars here come from within the job (hooks/plugins). Use the - // within-job predicate: from-job lets them reconfigure checkout, strict - // does not. - if env.IsCheckoutLockedFromWithinJob(tag, c.CheckoutOverrideMode) { + // checkout vars here come from within the job (hooks/plugins). from-job + // and none let them reconfigure checkout; only strict locks them. + if env.IsCheckoutLocked(tag, c.CheckoutOverrideMode) { continue } diff --git a/internal/job/executor.go b/internal/job/executor.go index 71d6b66d2f..8bb807e094 100644 --- a/internal/job/executor.go +++ b/internal/job/executor.go @@ -649,7 +649,7 @@ func (e *Executor) applyEnvironmentChanges(changes hook.EnvChanges) { var protected []string for k := range changes.Diff.Keys { if env.IsProtectedFromWithinJob(k) || - env.IsCheckoutLockedFromWithinJob(k, e.CheckoutOverrideMode) { + env.IsCheckoutLocked(k, e.CheckoutOverrideMode) { protected = append(protected, k) changes.Diff.Remove(k) } @@ -994,7 +994,7 @@ func (e *Executor) fetchAndSetSecrets(ctx context.Context) error { if env.IsProtected(pipelineSecret.EnvironmentVariable) { return fmt.Errorf("secret %q cannot set protected environment variable %q", pipelineSecret.Key, pipelineSecret.EnvironmentVariable) } - if env.IsCheckoutLocked(pipelineSecret.EnvironmentVariable, e.CheckoutOverrideMode) { + if env.IsCheckoutLockedForSecrets(pipelineSecret.EnvironmentVariable, e.CheckoutOverrideMode) { return fmt.Errorf("secret %q cannot set checkout-locked environment variable %q while BUILDKITE_CHECKOUT_OVERRIDE_MODE=%s", pipelineSecret.Key, pipelineSecret.EnvironmentVariable, e.CheckoutOverrideMode) } diff --git a/internal/job/integration/hooks_integration_test.go b/internal/job/integration/hooks_integration_test.go index ef316199fd..b2e95f9421 100644 --- a/internal/job/integration/hooks_integration_test.go +++ b/internal/job/integration/hooks_integration_test.go @@ -260,14 +260,13 @@ func TestEnvironmentHookCannotRelaxCheckoutOverrideMode(t *testing.T) { } } -func TestNoCommandEvalFloorsCheckoutOverrideModeToFromJob(t *testing.T) { +func TestNoCommandEvalFloorsCheckoutOverrideModeToStrict(t *testing.T) { t.Parallel() - // Disabling command-eval floors the mode up to from-job (none -> from-job; - // from-job and strict are unchanged). from-job only locks outside-job - // sources (backend job env, secrets), so a trusted agent hook can still set - // checkout vars even with command-eval off and mode=none. The floor's - // effect on outside-job sources is covered by the secrets integration test. + // Disabling command-eval floors the mode to strict regardless of the + // configured mode, so no source can inject git flags to bypass + // no-command-eval. Even a within-job hook (which from-job would allow) is + // blocked from setting checkout vars once command-eval is off. tester, err := NewExecutorTester(mainCtx) if err != nil { t.Fatalf("NewExecutorTester() error = %v", err) @@ -292,8 +291,8 @@ func TestNoCommandEvalFloorsCheckoutOverrideModeToFromJob(t *testing.T) { } tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { - if got := c.GetEnv("BUILDKITE_SKIP_CHECKOUT"); got != "true" { - _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want the hook's value to survive under from-job\n", got) + if got := c.GetEnv("BUILDKITE_SKIP_CHECKOUT"); got == "true" { + _, _ = fmt.Fprintf(c.Stderr, "BUILDKITE_SKIP_CHECKOUT=%q, want the command-eval floor to strict to block it\n", got) c.Exit(1) return } @@ -302,8 +301,8 @@ func TestNoCommandEvalFloorsCheckoutOverrideModeToFromJob(t *testing.T) { tester.RunAndCheck(t, "BUILDKITE_COMMAND_EVAL=false", "BUILDKITE_CHECKOUT_OVERRIDE_MODE=none") - if strings.Contains(tester.Output, "env vars were blocked") && strings.Contains(tester.Output, "BUILDKITE_SKIP_CHECKOUT") { - t.Fatalf("BUILDKITE_SKIP_CHECKOUT should not be blocked under from-job\noutput: %s", tester.Output) + if !strings.Contains(tester.Output, "env vars were blocked") || !strings.Contains(tester.Output, "BUILDKITE_SKIP_CHECKOUT") { + t.Fatalf("BUILDKITE_SKIP_CHECKOUT should be blocked once command-eval floors the mode to strict\noutput: %s", tester.Output) } } diff --git a/internal/job/integration/secrets_integration_test.go b/internal/job/integration/secrets_integration_test.go index 1e9cde7c38..5ee5d670a7 100644 --- a/internal/job/integration/secrets_integration_test.go +++ b/internal/job/integration/secrets_integration_test.go @@ -585,9 +585,9 @@ func TestSecretsIntegration_CheckoutOverrideMode(t *testing.T) { {name: "strict_rejects_sparse_checkout_paths_secret", envVar: "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", secretValue: "a/b", mode: "strict", wantErr: true}, // The lock must not over-block: a non-checkout secret is still allowed. {name: "strict_allows_unscoped_secret", envVar: "MY_CUSTOM_VAR", secretValue: "hello", mode: "strict"}, - // Disabling command-eval floors none up to from-job, so an outside-job - // secret that none would allow is now rejected. - {name: "none_floored_to_from_job_rejects_secret_when_command_eval_disabled", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", mode: "none", commandEvalDisabled: true, wantErr: true}, + // Disabling command-eval floors the mode to strict, so a secret that none + // would otherwise allow is now rejected. + {name: "none_floored_to_strict_rejects_secret_when_command_eval_disabled", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", mode: "none", commandEvalDisabled: true, wantErr: true}, } for _, tc := range tests { diff --git a/jobapi/env.go b/jobapi/env.go index 5075b83688..6ffae3ae81 100644 --- a/jobapi/env.go +++ b/jobapi/env.go @@ -144,7 +144,7 @@ func (s *Server) checkProtected(candidates []string) []string { // The Job API is only accessible from within the job, so allow writes // to vars that allow write from within job. if env.IsProtectedFromWithinJob(c) || - env.IsCheckoutLockedFromWithinJob(c, s.checkoutOverrideMode) { + env.IsCheckoutLocked(c, s.checkoutOverrideMode) { protected = append(protected, c) } } @@ -157,7 +157,7 @@ func (s *Server) checkProtected(candidates []string) []string { func (s *Server) protectedEnvMessage(protected []string) string { msg := fmt.Sprintf("the following environment variables are protected, and cannot be modified: % v", protected) for _, p := range protected { - if env.IsCheckoutLockedFromWithinJob(p, s.checkoutOverrideMode) { + if env.IsCheckoutLocked(p, s.checkoutOverrideMode) { msg += fmt.Sprintf(". Checkout-related variables are locked because BUILDKITE_CHECKOUT_OVERRIDE_MODE=%s", s.checkoutOverrideMode) break } From c323351ca5fbc82fb3308f2ff12e503de6b49eb5 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 09:53:16 -0400 Subject: [PATCH 42/57] Scope checkout-override none docs to scoped vars The none mode help and comment claimed any source could override the agent's checkout config. Mirror-infra vars and SUBMODULE_CLONE_CONFIG stay agent-authoritative in every mode, so scope the wording to the checkout-override-scoped vars and call out the always-locked exceptions. --- clicommand/global.go | 2 +- env/protected.go | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/clicommand/global.go b/clicommand/global.go index 4a293f1c98..19a6f3ee7a 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -209,7 +209,7 @@ var ( CheckoutOverrideModeFlag = cli.StringFlag{ Name: "checkout-override-mode", Value: "from-job", - Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ lets any source, including secrets, override. Disabling command-eval floors this at ′strict′.", checkoutOverrideModes), + Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ additionally lets secrets set them. Mirror configuration (path, lock timeout, checkout mode) and submodule clone config stay agent-authoritative in every mode. Disabling command-eval floors this at ′strict′.", checkoutOverrideModes), EnvVar: "BUILDKITE_CHECKOUT_OVERRIDE_MODE", } diff --git a/env/protected.go b/env/protected.go index 9818805ad3..f095e655b8 100644 --- a/env/protected.go +++ b/env/protected.go @@ -144,8 +144,10 @@ const ( // every source: pipeline/step env, secrets, hooks, plugins, and the Job API. CheckoutOverrideStrict - // CheckoutOverrideNone lets any source, including secrets, override agent - // checkout config. + // CheckoutOverrideNone lets any source, including secrets, override the + // checkout-override-scoped vars. Vars that are always agent-authoritative + // (the mirror-infra vars and SUBMODULE_CLONE_CONFIG in protectedEnv) are + // unaffected by the mode, so they stay locked even under none. CheckoutOverrideNone ) From a4534efbce83d2443d711da94121af3ec0375fcd Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 10:18:40 -0400 Subject: [PATCH 43/57] Test within-job block of checkout infra vars The within-job tightening of SUBMODULE_CLONE_CONFIG and MIRROR_CHECKOUT_MODE was only unit-tested via IsProtected. Add an integration test where an environment hook is blocked from setting them under the most permissive mode (none), proving they stay agent- authoritative regardless of the checkout-override mode. --- .../job/integration/hooks_integration_test.go | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/internal/job/integration/hooks_integration_test.go b/internal/job/integration/hooks_integration_test.go index b2e95f9421..bbc7240e30 100644 --- a/internal/job/integration/hooks_integration_test.go +++ b/internal/job/integration/hooks_integration_test.go @@ -209,6 +209,66 @@ func TestEnvironmentHookCheckoutOverrideMode(t *testing.T) { } } +func TestEnvironmentHookCannotSetCheckoutInfraVars(t *testing.T) { + t.Parallel() + + // SUBMODULE_CLONE_CONFIG and MIRROR_CHECKOUT_MODE are always agent- + // authoritative. Unlike the checkout-override-scoped vars, they're blocked + // from within-job sources regardless of the mode, so run under none (the most + // permissive mode) to prove the mode can't relax them. + infraVars := []struct { + envVar string + envValue string + }{ + {"BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", "protocol.file.allow=always"}, + {"BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", "dissociate"}, + } + + for _, tc := range infraVars { + t.Run(tc.envVar, func(t *testing.T) { + t.Parallel() + + tester, err := NewExecutorTester(mainCtx) + if err != nil { + t.Fatalf("NewExecutorTester() error = %v", err) + } + defer tester.Close() + + filename := "environment" + script := []string{ + "#!/usr/bin/env bash", + fmt.Sprintf("export %s=%s", tc.envVar, tc.envValue), + } + if runtime.GOOS == "windows" { + filename = "environment.bat" + script = []string{ + "@echo off", + fmt.Sprintf("set %s=%s", tc.envVar, tc.envValue), + } + } + + if err := os.WriteFile(filepath.Join(tester.HooksDir, filename), []byte(strings.Join(script, "\n")), 0o700); err != nil { + t.Fatalf("os.WriteFile(%q, script, 0o700) = %v", filename, err) + } + + tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + if got := c.GetEnv(tc.envVar); got == tc.envValue { + _, _ = fmt.Fprintf(c.Stderr, "%s=%q, want the within-job block to strip the hook's value\n", tc.envVar, got) + c.Exit(1) + return + } + c.Exit(0) + }) + + tester.RunAndCheck(t, "BUILDKITE_CHECKOUT_OVERRIDE_MODE=none") + + if !strings.Contains(tester.Output, "env vars were blocked") || !strings.Contains(tester.Output, tc.envVar) { + t.Fatalf("output did not report %q as blocked\noutput: %s", tc.envVar, tester.Output) + } + }) + } +} + func TestEnvironmentHookCannotRelaxCheckoutOverrideMode(t *testing.T) { t.Parallel() From 0a27de24ba6a8f9bce4eeb1197432bc28aca5645 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 11:15:05 -0400 Subject: [PATCH 44/57] Rename misleading FlooredForCommandEval helper The mode enum iota is FromJob=0, Strict=1, None=2, so "floor at strict" read as a numeric lower-bound clamp, but the helper unconditionally forces strict when command-eval is disabled (its own comment said "raised"). Rename to RestrictedForCommandEval and align the "floors"->"forces" wording in the resolver doc, config-helper comments, scope comment, and flag usage. --- clicommand/agent_start.go | 2 +- clicommand/bootstrap.go | 2 +- clicommand/global.go | 8 ++++---- env/protected.go | 13 +++++++------ env/protected_test.go | 12 ++++++------ 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index 2baa989a6f..a1ed3e8299 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -234,7 +234,7 @@ type AgentStartConfig struct { DisconnectAfterJobTimeout int `cli:"disconnect-after-job-timeout" deprecated:"Use disconnect-after-idle-timeout instead"` } -// checkoutOverrideMode resolves the configured mode, flooring it for command-eval. +// checkoutOverrideMode resolves the configured mode, forcing strict when command-eval is off. // AgentStartConfig stores NoCommandEval (the BootstrapConfig sibling stores CommandEval). func (cfg *AgentStartConfig) checkoutOverrideMode() (env.CheckoutOverrideMode, error) { return resolveCheckoutOverrideMode(cfg.CheckoutOverrideMode, !cfg.NoCommandEval) diff --git a/clicommand/bootstrap.go b/clicommand/bootstrap.go index f46c1f6bf5..f258eb767d 100644 --- a/clicommand/bootstrap.go +++ b/clicommand/bootstrap.go @@ -123,7 +123,7 @@ type BootstrapConfig struct { CheckoutAttempts int `cli:"checkout-attempts"` } -// checkoutOverrideMode resolves the configured mode, flooring it for command-eval. +// checkoutOverrideMode resolves the configured mode, forcing strict when command-eval is off. // BootstrapConfig stores CommandEval (the AgentStartConfig sibling stores NoCommandEval). func (cfg *BootstrapConfig) checkoutOverrideMode() (env.CheckoutOverrideMode, error) { return resolveCheckoutOverrideMode(cfg.CheckoutOverrideMode, cfg.CommandEval) diff --git a/clicommand/global.go b/clicommand/global.go index 19a6f3ee7a..807f418923 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -209,7 +209,7 @@ var ( CheckoutOverrideModeFlag = cli.StringFlag{ Name: "checkout-override-mode", Value: "from-job", - Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ additionally lets secrets set them. Mirror configuration (path, lock timeout, checkout mode) and submodule clone config stay agent-authoritative in every mode. Disabling command-eval floors this at ′strict′.", checkoutOverrideModes), + Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ additionally lets secrets set them. Mirror configuration (path, lock timeout, checkout mode) and submodule clone config stay agent-authoritative in every mode. Disabling command-eval forces this to ′strict′.", checkoutOverrideModes), EnvVar: "BUILDKITE_CHECKOUT_OVERRIDE_MODE", } @@ -341,8 +341,8 @@ type APIConfig struct { NoHTTP2 bool `cli:"no-http2"` } -// resolveCheckoutOverrideMode parses a checkout-override mode value and floors it -// at strict when command-eval is disabled, so a job can't use pipeline/step env +// resolveCheckoutOverrideMode parses a checkout-override mode value and forces it +// to strict when command-eval is disabled, so a job can't use pipeline/step env // or secret git flags to bypass no-command-eval. AgentStartConfig and // BootstrapConfig store the command-eval flag with opposite polarity, so each // passes the resolved boolean here. @@ -351,7 +351,7 @@ func resolveCheckoutOverrideMode(raw string, commandEvalEnabled bool) (env.Check if err != nil { return mode, err } - return mode.FlooredForCommandEval(commandEvalEnabled), nil + return mode.RestrictedForCommandEval(commandEvalEnabled), nil } func globalFlags() []cli.Flag { diff --git a/env/protected.go b/env/protected.go index f095e655b8..2cacadcdb8 100644 --- a/env/protected.go +++ b/env/protected.go @@ -83,7 +83,7 @@ var protectedEnv = map[string]protection{ // them against every source; none leaves them fully open, including secrets. // Locking matters because git is riddled with shell injections, so letting a job // set git flags would otherwise be a way to bypass protections like -// no-command-eval (which is why disabling command-eval floors the mode at +// no-command-eval (which is why disabling command-eval forces the mode to // strict). Vars here must not also appear in protectedEnv; the two maps are // disjoint. var checkoutOverrideScope = map[string]struct{}{ @@ -191,11 +191,12 @@ func ParseCheckoutOverrideMode(s string) (CheckoutOverrideMode, error) { } } -// FlooredForCommandEval restricts the mode so command-eval can't be bypassed: -// when command-eval is disabled, the mode is raised to CheckoutOverrideStrict so -// no source (pipeline/step env, secrets, hooks, plugins, or the Job API) can -// inject git flags that would otherwise circumvent no-command-eval. -func (m CheckoutOverrideMode) FlooredForCommandEval(commandEvalEnabled bool) CheckoutOverrideMode { +// RestrictedForCommandEval tightens the mode so command-eval can't be bypassed: +// when command-eval is disabled, it returns CheckoutOverrideStrict so no source +// (pipeline/step env, secrets, hooks, plugins, or the Job API) can inject git +// flags that would otherwise circumvent no-command-eval. Otherwise it returns the +// mode unchanged. +func (m CheckoutOverrideMode) RestrictedForCommandEval(commandEvalEnabled bool) CheckoutOverrideMode { if !commandEvalEnabled { return CheckoutOverrideStrict } diff --git a/env/protected_test.go b/env/protected_test.go index 68ac8fdd1b..89addd9449 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -209,10 +209,10 @@ func TestCheckoutLockPredicates(t *testing.T) { } } -func TestFlooredForCommandEval(t *testing.T) { +func TestRestrictedForCommandEval(t *testing.T) { t.Parallel() - // With command-eval enabled the mode is unchanged; disabling it raises every + // With command-eval enabled the mode is unchanged; disabling it forces every // mode to strict so no source can inject git flags to bypass no-command-eval. cases := []struct { mode CheckoutOverrideMode @@ -225,11 +225,11 @@ func TestFlooredForCommandEval(t *testing.T) { } for _, tc := range cases { - if got := tc.mode.FlooredForCommandEval(true); got != tc.commandEvalOn { - t.Errorf("%v.FlooredForCommandEval(true) = %v, want %v", tc.mode, got, tc.commandEvalOn) + if got := tc.mode.RestrictedForCommandEval(true); got != tc.commandEvalOn { + t.Errorf("%v.RestrictedForCommandEval(true) = %v, want %v", tc.mode, got, tc.commandEvalOn) } - if got := tc.mode.FlooredForCommandEval(false); got != tc.commandEvalOffMax { - t.Errorf("%v.FlooredForCommandEval(false) = %v, want %v", tc.mode, got, tc.commandEvalOffMax) + if got := tc.mode.RestrictedForCommandEval(false); got != tc.commandEvalOffMax { + t.Errorf("%v.RestrictedForCommandEval(false) = %v, want %v", tc.mode, got, tc.commandEvalOffMax) } } } From 9c7b88592d093469247a1cb04b53bab554faf012 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 11:18:46 -0400 Subject: [PATCH 45/57] Scope strict checkout-override wording to what it governs The strict help text and CheckoutOverrideStrict doc claimed the agent is authoritative against every source, but GIT_SSH_KEY and GIT_LFS_ENABLED are governed by neither map (job-settable in every mode) and REPO/REFSPEC stay mutableFromWithinJob. Scope the wording, document the intentional exclusions next to checkoutOverrideScope, and add a test pinning them so a future change that scopes or protects them has to update the expectation on purpose. --- clicommand/global.go | 2 +- env/protected.go | 16 ++++++++++++++-- env/protected_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/clicommand/global.go b/clicommand/global.go index 807f418923..dc5b407495 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -209,7 +209,7 @@ var ( CheckoutOverrideModeFlag = cli.StringFlag{ Name: "checkout-override-mode", Value: "from-job", - Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ additionally lets secrets set them. Mirror configuration (path, lock timeout, checkout mode) and submodule clone config stay agent-authoritative in every mode. Disabling command-eval forces this to ′strict′.", checkoutOverrideModes), + Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ additionally lets secrets set them. Mirror configuration (path, lock timeout, checkout mode) and submodule clone config stay agent-authoritative in every mode. The checkout SSH key and Git LFS toggle are not governed by this flag and stay job-settable in every mode. Disabling command-eval forces this to ′strict′.", checkoutOverrideModes), EnvVar: "BUILDKITE_CHECKOUT_OVERRIDE_MODE", } diff --git a/env/protected.go b/env/protected.go index 2cacadcdb8..536a031ca2 100644 --- a/env/protected.go +++ b/env/protected.go @@ -100,6 +100,16 @@ var checkoutOverrideScope = map[string]struct{}{ "BUILDKITE_SKIP_CHECKOUT": {}, } +// Some checkout-related vars are intentionally governed by neither the mode nor +// checkoutOverrideScope. BUILDKITE_GIT_SSH_KEY and BUILDKITE_GIT_LFS_ENABLED are +// in no map at all, so any source may set them in every mode: a job supplying its +// own deploy key or LFS toggle configures its own checkout without escalating the +// agent's privileges, and neither is a shell-flag injection vector. BUILDKITE_REPO +// and BUILDKITE_REFSPEC stay mutableFromWithinJob in protectedEnv, so hooks and +// plugins may set them even under strict, while backend job env and secrets are +// still blocked by IsProtected. The checkout-override mode does not change any of +// this. + // IsProtected reports whether the environment variable is write-protected when // the write is coming from job-level env or secrets. func IsProtected(name string) bool { @@ -140,8 +150,10 @@ const ( // which locked checkout-scoped vars against backend job env. CheckoutOverrideFromJob CheckoutOverrideMode = iota - // CheckoutOverrideStrict makes agent checkout config authoritative against - // every source: pipeline/step env, secrets, hooks, plugins, and the Job API. + // CheckoutOverrideStrict locks the checkoutOverrideScope vars against every + // source: pipeline/step env, secrets, hooks, plugins, and the Job API. Vars + // outside that scope (see the exclusions note on checkoutOverrideScope) are + // unaffected by the mode. CheckoutOverrideStrict // CheckoutOverrideNone lets any source, including secrets, override the diff --git a/env/protected_test.go b/env/protected_test.go index 89addd9449..037bdef8a1 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -209,6 +209,48 @@ func TestCheckoutLockPredicates(t *testing.T) { } } +// TestCheckoutOverrideModeExclusions pins the checkout-related vars that the mode +// deliberately does not govern, so a future change that scopes or protects them +// has to update these expectations on purpose. +func TestCheckoutOverrideModeExclusions(t *testing.T) { + t.Parallel() + + // GIT_SSH_KEY and GIT_LFS_ENABLED are in neither map, so any source can set + // them in every mode, including strict. + for _, name := range []string{"BUILDKITE_GIT_SSH_KEY", "BUILDKITE_GIT_LFS_ENABLED"} { + if IsProtected(name) { + t.Errorf("IsProtected(%q) = true, want false", name) + } + if IsCheckoutOverrideScoped(name) { + t.Errorf("IsCheckoutOverrideScoped(%q) = true, want false", name) + } + if IsCheckoutLocked(name, CheckoutOverrideStrict) { + t.Errorf("IsCheckoutLocked(%q, strict) = true, want false", name) + } + if IsCheckoutLockedForSecrets(name, CheckoutOverrideStrict) { + t.Errorf("IsCheckoutLockedForSecrets(%q, strict) = true, want false", name) + } + } + + // REPO and REFSPEC are not checkout-override-scoped, so the mode never locks + // them; they stay mutableFromWithinJob in protectedEnv (hooks and plugins may + // set them even under strict, while backend job env and secrets are blocked). + for _, name := range []string{"BUILDKITE_REPO", "BUILDKITE_REFSPEC"} { + if IsCheckoutOverrideScoped(name) { + t.Errorf("IsCheckoutOverrideScoped(%q) = true, want false", name) + } + if IsCheckoutLocked(name, CheckoutOverrideStrict) { + t.Errorf("IsCheckoutLocked(%q, strict) = true, want false", name) + } + if !IsProtected(name) { + t.Errorf("IsProtected(%q) = false, want true", name) + } + if IsProtectedFromWithinJob(name) { + t.Errorf("IsProtectedFromWithinJob(%q) = true, want false", name) + } + } +} + func TestRestrictedForCommandEval(t *testing.T) { t.Parallel() From b5cb45f077425b46555264f0bda8589e972499ab Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 11:19:51 -0400 Subject: [PATCH 46/57] Inline single-use checkoutOverrideModes alias The alias was referenced only by the flag usage string and, unlike its sibling mirrorCheckoutModes, never used for validation (that goes through ParseCheckoutOverrideMode). Use env.CheckoutOverrideModeNames directly. --- clicommand/agent_start.go | 2 -- clicommand/global.go | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index a1ed3e8299..a3dfcbd34b 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -78,8 +78,6 @@ var ( mirrorCheckoutModes = []string{"dissociate", "reference"} - checkoutOverrideModes = env.CheckoutOverrideModeNames - buildkiteSetEnvironmentVariables = []*regexp.Regexp{ regexp.MustCompile("^BUILDKITE$"), regexp.MustCompile("^BUILDKITE_.*$"), diff --git a/clicommand/global.go b/clicommand/global.go index dc5b407495..2573f7d8eb 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -209,7 +209,7 @@ var ( CheckoutOverrideModeFlag = cli.StringFlag{ Name: "checkout-override-mode", Value: "from-job", - Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ additionally lets secrets set them. Mirror configuration (path, lock timeout, checkout mode) and submodule clone config stay agent-authoritative in every mode. The checkout SSH key and Git LFS toggle are not governed by this flag and stay job-settable in every mode. Disabling command-eval forces this to ′strict′.", checkoutOverrideModes), + Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ additionally lets secrets set them. Mirror configuration (path, lock timeout, checkout mode) and submodule clone config stay agent-authoritative in every mode. The checkout SSH key and Git LFS toggle are not governed by this flag and stay job-settable in every mode. Disabling command-eval forces this to ′strict′.", env.CheckoutOverrideModeNames), EnvVar: "BUILDKITE_CHECKOUT_OVERRIDE_MODE", } From cad9ad348a18a90f29b5d80d5e3ef42150ce94c1 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 11:21:58 -0400 Subject: [PATCH 47/57] Test secrets can't set locked checkout-infra vars GIT_MIRROR_CHECKOUT_MODE and GIT_SUBMODULE_CLONE_CONFIG became fully protectedEnv in this branch, but secret rejection was only covered transitively via IsProtected. Add an integration test mapping a secret to each (as the sole secret) under none, asserting rejection so a future move into checkoutOverrideScope has to update the expectation. --- .../integration/secrets_integration_test.go | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/internal/job/integration/secrets_integration_test.go b/internal/job/integration/secrets_integration_test.go index 5ee5d670a7..9495348e4d 100644 --- a/internal/job/integration/secrets_integration_test.go +++ b/internal/job/integration/secrets_integration_test.go @@ -564,6 +564,54 @@ func TestSecretsIntegration_ProtectedEnvironmentVariableRejection(t *testing.T) } } +func TestSecretsIntegration_ProtectedCheckoutInfraRejection(t *testing.T) { + t.Parallel() + + // These checkout-infra vars are always agent-authoritative (protectedEnv, not + // checkoutOverrideScope), so a secret can never set them, even under the most + // permissive checkout-override mode (none). Each is the sole secret so the + // rejection is unambiguously about it (fetchAndSetSecrets stops at the first + // offender). Pins against tier drift: moving either into checkoutOverrideScope + // would let a secret set it under none. + for _, envVar := range []string{ + "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", + "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", + } { + t.Run(envVar, func(t *testing.T) { + t.Parallel() + + tester, err := NewExecutorTester(mainCtx) + if err != nil { + t.Fatalf("setting up executor tester: %v", err) + } + defer tester.Close() + + apiServer := setupSecretsAPIServer(t, map[string]string{"INFRA_SECRET": "infra-secret-value"}) + defer apiServer.Close() + + secretsJSON, err := json.Marshal([]pipeline.Secret{{ + Key: "INFRA_SECRET", + EnvironmentVariable: envVar, + }}) + if err != nil { + t.Fatalf("marshaling secrets: %v", err) + } + + err = tester.Run(t, + fmt.Sprintf("BUILDKITE_SECRETS_CONFIG=%s", string(secretsJSON)), + fmt.Sprintf("BUILDKITE_AGENT_ENDPOINT=%s", apiServer.URL), + "BUILDKITE_CHECKOUT_OVERRIDE_MODE=none", + ) + if err == nil { + t.Fatalf("expected job to fail due to protected env var rejection, but it succeeded. Full output: %s", tester.Output) + } + if !strings.Contains(tester.Output, "cannot set protected environment variable") || !strings.Contains(tester.Output, envVar) { + t.Fatalf("expected output to mention protected env rejection of %s, got: %s", envVar, tester.Output) + } + }) + } +} + func TestSecretsIntegration_CheckoutOverrideMode(t *testing.T) { t.Parallel() From 283cc2d0e1b00bda35c990efde3553e5d792172f Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 12:06:50 -0400 Subject: [PATCH 48/57] Make mirror clone flags and skip-update agent-only GIT_CLONE_MIRROR_FLAGS and GIT_MIRRORS_SKIP_UPDATE were in checkoutOverrideScope, so under none a pipeline or secret could set them. CLONE_MIRROR_FLAGS is applied to the shared git clone --mirror, so a job setting it is a cross-job injection vector. Mirrors are agent-local infra the backend has no concept of, so move both to protectedEnv{} (agent-only in every mode, matching the other mirror vars) and emit them via setEnv. Move their integration cases to TestCheckoutInfraVarsAreAgentAuthoritative. --- .../job_environment_integration_test.go | 71 +++++-------------- agent/job_runner.go | 4 +- env/protected.go | 11 ++- env/protected_test.go | 6 +- 4 files changed, 33 insertions(+), 59 deletions(-) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index 593ed1f2b2..d72b27dccd 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -593,56 +593,6 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { wantEnvValue: "-ffxdq", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLEAN_FLAGS"}, }, - { - name: "none_allows_job_env_to_override_clone_mirror_flags", - varName: "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", - jobEnv: map[string]string{ - "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": "--mirror", - }, - agentCfg: agent.AgentConfiguration{ - GitCloneMirrorFlags: "--bare", - CheckoutOverrideMode: env.CheckoutOverrideNone, - }, - wantEnvValue: "--mirror", - }, - { - name: "strict_locks_clone_mirror_flags_to_agent_config", - varName: "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", - jobEnv: map[string]string{ - "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": "--mirror", - }, - agentCfg: agent.AgentConfiguration{ - GitCloneMirrorFlags: "--bare", - CheckoutOverrideMode: env.CheckoutOverrideStrict, - }, - wantEnvValue: "--bare", - wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_MIRROR_FLAGS"}, - }, - { - name: "none_allows_job_env_to_override_mirrors_skip_update", - varName: "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", - jobEnv: map[string]string{ - "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": "true", - }, - agentCfg: agent.AgentConfiguration{ - GitMirrorsSkipUpdate: false, - CheckoutOverrideMode: env.CheckoutOverrideNone, - }, - wantEnvValue: "true", - }, - { - name: "strict_locks_mirrors_skip_update_to_agent_config", - varName: "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", - jobEnv: map[string]string{ - "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": "true", - }, - agentCfg: agent.AgentConfiguration{ - GitMirrorsSkipUpdate: false, - CheckoutOverrideMode: env.CheckoutOverrideStrict, - }, - wantEnvValue: "false", - wantIgnoredEnvVars: []string{"BUILDKITE_GIT_MIRRORS_SKIP_UPDATE"}, - }, { name: "none_allows_job_env_to_override_checkout_timeout", varName: "BUILDKITE_GIT_CHECKOUT_TIMEOUT", @@ -749,9 +699,10 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { func TestCheckoutInfraVarsAreAgentAuthoritative(t *testing.T) { t.Parallel() - // SSH_KEYSCAN, GIT_MIRRORS_PATH, GIT_MIRRORS_LOCK_TIMEOUT and - // GIT_MIRROR_CHECKOUT_MODE are agent-only: job env cannot override them even - // under the most permissive checkout-override mode (none). + // SSH_KEYSCAN, GIT_MIRRORS_PATH, GIT_MIRRORS_LOCK_TIMEOUT, + // GIT_MIRROR_CHECKOUT_MODE, GIT_CLONE_MIRROR_FLAGS and GIT_MIRRORS_SKIP_UPDATE + // are agent-only: job env cannot override them even under the most permissive + // checkout-override mode (none). tests := []struct { name string varName string @@ -787,6 +738,20 @@ func TestCheckoutInfraVarsAreAgentAuthoritative(t *testing.T) { agentCfg: agent.AgentConfiguration{GitMirrorCheckoutMode: "raw", CheckoutOverrideMode: env.CheckoutOverrideNone}, wantEnvValue: "raw", }, + { + name: "git_clone_mirror_flags", + varName: "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", + jobEnvValue: "--mirror", + agentCfg: agent.AgentConfiguration{GitCloneMirrorFlags: "--bare", CheckoutOverrideMode: env.CheckoutOverrideNone}, + wantEnvValue: "--bare", + }, + { + name: "git_mirrors_skip_update", + varName: "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", + jobEnvValue: "true", + agentCfg: agent.AgentConfiguration{GitMirrorsSkipUpdate: false, CheckoutOverrideMode: env.CheckoutOverrideNone}, + wantEnvValue: "false", + }, } for _, tc := range tests { diff --git a/agent/job_runner.go b/agent/job_runner.go index 683edc2fc1..ecd0696ae3 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -609,7 +609,7 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setEnv("BUILDKITE_BUILD_PATH", r.conf.AgentConfiguration.BuildPath) setEnv("BUILDKITE_SOCKETS_PATH", r.conf.AgentConfiguration.SocketsPath) setEnv("BUILDKITE_GIT_MIRRORS_PATH", r.conf.AgentConfiguration.GitMirrorsPath) - setCheckoutEnv("BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", fmt.Sprint(r.conf.AgentConfiguration.GitMirrorsSkipUpdate)) + setEnv("BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", fmt.Sprint(r.conf.AgentConfiguration.GitMirrorsSkipUpdate)) setEnv("BUILDKITE_HOOKS_PATH", r.conf.AgentConfiguration.HooksPath) setEnv("BUILDKITE_ADDITIONAL_HOOKS_PATHS", strings.Join(r.conf.AgentConfiguration.AdditionalHooksPaths, ",")) setEnv("BUILDKITE_PLUGINS_PATH", r.conf.AgentConfiguration.PluginsPath) @@ -660,7 +660,7 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setCheckoutEnv("BUILDKITE_GIT_CLONE_FLAGS", r.conf.AgentConfiguration.GitCloneFlags) setCheckoutEnv("BUILDKITE_GIT_FETCH_FLAGS", r.conf.AgentConfiguration.GitFetchFlags) setCheckoutEnv("BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", strings.Join(r.conf.AgentConfiguration.GitSparseCheckoutPaths, ",")) - setCheckoutEnv("BUILDKITE_GIT_CLONE_MIRROR_FLAGS", r.conf.AgentConfiguration.GitCloneMirrorFlags) + setEnv("BUILDKITE_GIT_CLONE_MIRROR_FLAGS", r.conf.AgentConfiguration.GitCloneMirrorFlags) setEnv("BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", r.conf.AgentConfiguration.GitMirrorCheckoutMode) setCheckoutEnv("BUILDKITE_GIT_CLEAN_FLAGS", r.conf.AgentConfiguration.GitCleanFlags) setEnv("BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitMirrorsLockTimeout)) diff --git a/env/protected.go b/env/protected.go index 536a031ca2..e2693658bd 100644 --- a/env/protected.go +++ b/env/protected.go @@ -28,6 +28,13 @@ type protection struct { // submodule clones (an injection vector) and has no backend customization, so // it stays agent-authoritative rather than in checkoutOverrideScope. // +// The mirror-infra vars (BUILDKITE_GIT_MIRRORS_PATH, _LOCK_TIMEOUT, +// _SKIP_UPDATE, BUILDKITE_GIT_MIRROR_CHECKOUT_MODE, and +// BUILDKITE_GIT_CLONE_MIRROR_FLAGS) are likewise agent-only: the mirror is shared +// across jobs on the host and the backend has no concept of it. CLONE_MIRROR_FLAGS +// in particular is applied to the shared `git clone --mirror`, so letting a job +// set it would be a cross-job injection vector. +// // The actual enforcement of protected env within the agent level (overriding // job-level env vars based on agent configuration) happens implicitly rather // than relying on this map - see createEnvironment in agent/job_runner.go. @@ -58,9 +65,11 @@ var protectedEnv = map[string]protection{ "BUILDKITE_COMMAND_EVAL": {}, "BUILDKITE_CONFIG_PATH": {}, "BUILDKITE_CONTAINER_COUNT": {}, + "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": {}, "BUILDKITE_GIT_COMMIT_VERIFICATION": {}, "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT": {}, "BUILDKITE_GIT_MIRRORS_PATH": {}, + "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": {}, "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE": {}, "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG": {}, "BUILDKITE_HOOKS_PATH": {}, @@ -91,9 +100,7 @@ var checkoutOverrideScope = map[string]struct{}{ "BUILDKITE_GIT_CHECKOUT_TIMEOUT": {}, "BUILDKITE_GIT_CLEAN_FLAGS": {}, "BUILDKITE_GIT_CLONE_FLAGS": {}, - "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": {}, "BUILDKITE_GIT_FETCH_FLAGS": {}, - "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": {}, "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": {}, "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS": {}, "BUILDKITE_GIT_SUBMODULES": {}, diff --git a/env/protected_test.go b/env/protected_test.go index 037bdef8a1..a45e962865 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -18,8 +18,10 @@ func TestProtectedEnv(t *testing.T) { "BUILDKITE_CONFIG_PATH", "BUILDKITE_CHECKOUT_OVERRIDE_MODE", "BUILDKITE_CONTAINER_COUNT", + "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", "BUILDKITE_GIT_MIRRORS_PATH", + "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", "BUILDKITE_HOOKS_PATH", @@ -77,10 +79,8 @@ func TestCheckoutOverrideScope(t *testing.T) { "BUILDKITE_GIT_CHECKOUT_FLAGS", "BUILDKITE_GIT_CHECKOUT_TIMEOUT", "BUILDKITE_GIT_CLONE_FLAGS", - "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", "BUILDKITE_GIT_CLEAN_FLAGS", "BUILDKITE_GIT_FETCH_FLAGS", - "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", "BUILDKITE_GIT_SUBMODULES", "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", "BUILDKITE_GIT_SPARSE_CHECKOUT_PATHS", @@ -103,7 +103,9 @@ func TestCheckoutOverrideScope(t *testing.T) { for _, envVar := range []string{ "BUILDKITE_GIT_MIRRORS_PATH", "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", + "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", "BUILDKITE_GIT_MIRROR_CHECKOUT_MODE", + "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", "BUILDKITE_GIT_SUBMODULE_CLONE_CONFIG", "BUILDKITE_SSH_KEYSCAN", "BUILDKITE_COMMAND_EVAL", From 44f9972aa594f86ddcf2a95a96cda7d1324efd4c Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 12:07:44 -0400 Subject: [PATCH 49/57] Generalize mirror wording in checkout-override help Follow-up to making clone-mirror-flags and mirrors-skip-update agent-only: the flag help enumerated only path/lock-timeout/checkout-mode as the always agent-authoritative mirror vars, which is now incomplete. Generalize to all mirror configuration. --- clicommand/global.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clicommand/global.go b/clicommand/global.go index 2573f7d8eb..219bf35c2d 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -209,7 +209,7 @@ var ( CheckoutOverrideModeFlag = cli.StringFlag{ Name: "checkout-override-mode", Value: "from-job", - Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ additionally lets secrets set them. Mirror configuration (path, lock timeout, checkout mode) and submodule clone config stay agent-authoritative in every mode. The checkout SSH key and Git LFS toggle are not governed by this flag and stay job-settable in every mode. Disabling command-eval forces this to ′strict′.", env.CheckoutOverrideModeNames), + Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ additionally lets secrets set them. All mirror configuration and submodule clone config stay agent-authoritative in every mode. The checkout SSH key and Git LFS toggle are not governed by this flag and stay job-settable in every mode. Disabling command-eval forces this to ′strict′.", env.CheckoutOverrideModeNames), EnvVar: "BUILDKITE_CHECKOUT_OVERRIDE_MODE", } From 87561d2578e511239670301dc62d732019572fee Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 12:51:23 -0400 Subject: [PATCH 50/57] Note checkout-override floor on no-command-eval flag --- clicommand/agent_start.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index a3dfcbd34b..50352771d7 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -601,7 +601,7 @@ var AgentStartCommand = cli.Command{ }, cli.BoolFlag{ Name: "no-command-eval", - Usage: "Don't allow this agent to run arbitrary console commands, including plugins (default: false)", + Usage: "Don't allow this agent to run arbitrary console commands, including plugins; also forces checkout-override-mode to 'strict' (default: false)", EnvVar: "BUILDKITE_NO_COMMAND_EVAL", }, cli.BoolFlag{ From 75f836edb8ea73ecc544ecd5a6f5206eca925190 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 13:20:55 -0400 Subject: [PATCH 51/57] Block backend job env under from-job checkout mode from-job was intended to match the agent's historical behaviour, which blocked the backend job env (pipeline/step env) from setting checkout-scoped vars. It was instead letting backend job env override them, reopening the git-flag injection vector the mode exists to close. Lock the backend job env in every mode except none (the same rule secrets follow); only within-job sources (hooks, plugins, Job API) keep the strict-only lock. --- .../job_environment_integration_test.go | 40 +++++++++++-------- agent/job_runner.go | 10 +++-- clicommand/global.go | 2 +- env/protected.go | 32 ++++++++------- env/protected_test.go | 7 +++- 5 files changed, 54 insertions(+), 37 deletions(-) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index d72b27dccd..5081e95d23 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -269,9 +269,10 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { }, { // The zero value (unset CheckoutOverrideMode) is the default, from-job. - // Pin that the default lets pipeline/step env override agent checkout - // config, so a future predicate change can't silently lock it back down. - name: "default_allows_job_env_to_override_clone_flags", + // Pin that the default locks the backend job env out of checkout config + // (matching the agent's historical behaviour), so a future predicate + // change can't silently reopen it. + name: "default_locks_clone_flags_to_agent_config", varName: "BUILDKITE_GIT_CLONE_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CLONE_FLAGS": "--no-tags", @@ -279,12 +280,13 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { agentCfg: agent.AgentConfiguration{ GitCloneFlags: "--mirror", }, - wantEnvValue: "--no-tags", + wantEnvValue: "--mirror", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_FLAGS"}, }, { // Same as above but with the mode named explicitly, pinning that from-job - // (not just the zero value) allows the pipeline/step env override. - name: "from_job_allows_job_env_to_override_clone_flags", + // (not just the zero value) locks the backend job env, matching strict. + name: "from_job_locks_clone_flags_to_agent_config", varName: "BUILDKITE_GIT_CLONE_FLAGS", jobEnv: map[string]string{ "BUILDKITE_GIT_CLONE_FLAGS": "--no-tags", @@ -293,13 +295,15 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { GitCloneFlags: "--mirror", CheckoutOverrideMode: env.CheckoutOverrideFromJob, }, - wantEnvValue: "--no-tags", + wantEnvValue: "--mirror", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_FLAGS"}, }, // The four vars below use the conditional-emit branch in createEnvironment // (a separate code path from setCheckoutEnv). Pin that from-job takes the - // deferring branch for them, so job env wins, matching none. + // locking branch for them, so agent config wins over backend job env, + // matching strict. { - name: "from_job_allows_job_env_to_enable_submodules", + name: "from_job_locks_submodules_to_agent_config", varName: "BUILDKITE_GIT_SUBMODULES", jobEnv: map[string]string{ "BUILDKITE_GIT_SUBMODULES": "true", @@ -308,10 +312,11 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { GitSubmodules: false, CheckoutOverrideMode: env.CheckoutOverrideFromJob, }, - wantEnvValue: "true", + wantEnvValue: "false", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SUBMODULES"}, }, { - name: "from_job_allows_job_env_to_override_skip_checkout", + name: "from_job_locks_skip_checkout_to_agent_config", varName: "BUILDKITE_SKIP_CHECKOUT", jobEnv: map[string]string{ "BUILDKITE_SKIP_CHECKOUT": "false", @@ -320,10 +325,11 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { SkipCheckout: true, CheckoutOverrideMode: env.CheckoutOverrideFromJob, }, - wantEnvValue: "false", + wantEnvValue: "true", + wantIgnoredEnvVars: []string{"BUILDKITE_SKIP_CHECKOUT"}, }, { - name: "from_job_allows_job_env_to_override_skip_fetch_existing_commits", + name: "from_job_locks_skip_fetch_existing_commits_to_agent_config", varName: "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", jobEnv: map[string]string{ "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": "false", @@ -332,10 +338,11 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { GitSkipFetchExistingCommits: true, CheckoutOverrideMode: env.CheckoutOverrideFromJob, }, - wantEnvValue: "false", + wantEnvValue: "true", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS"}, }, { - name: "from_job_allows_job_env_to_override_checkout_timeout", + name: "from_job_locks_checkout_timeout_to_agent_config", varName: "BUILDKITE_GIT_CHECKOUT_TIMEOUT", jobEnv: map[string]string{ "BUILDKITE_GIT_CHECKOUT_TIMEOUT": "99", @@ -344,7 +351,8 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { GitCheckoutTimeout: 60, CheckoutOverrideMode: env.CheckoutOverrideFromJob, }, - wantEnvValue: "99", + wantEnvValue: "60", + wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CHECKOUT_TIMEOUT"}, }, { name: "none_allows_job_env_to_enable_submodules", diff --git a/agent/job_runner.go b/agent/job_runner.go index ecd0696ae3..ba6429a4cd 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -405,10 +405,14 @@ func (r *JobRunner) normalizeVerificationBehavior(behavior string) (string, erro // Creates the environment variables that will be used in the process and writes a flat environment file func (r *JobRunner) createEnvironment(ctx context.Context) ([]string, error) { - // Checkout-scoped vars are locked against backend job env only under strict. - // from-job (the default) and none let pipeline/step env override agent config. + // Checkout-scoped vars are locked against the backend job env in every mode + // except none; only none lets pipeline/step env override agent config. strict + // and from-job (the default) keep the agent authoritative here, matching the + // agent's historical behaviour. This is the same rule secrets follow (see + // IsCheckoutLockedForSecrets); within-job sources (hooks, plugins, the Job API) + // are governed separately and only strict locks them. checkoutMode := r.conf.AgentConfiguration.CheckoutOverrideMode - checkoutLockedFromJobEnv := checkoutMode == envutil.CheckoutOverrideStrict + checkoutLockedFromJobEnv := checkoutMode != envutil.CheckoutOverrideNone // Create a clone of our jobs environment. We'll then set the // environment variables provided by the agent, which will override any diff --git a/clicommand/global.go b/clicommand/global.go index 219bf35c2d..93f8a61b21 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -209,7 +209,7 @@ var ( CheckoutOverrideModeFlag = cli.StringFlag{ Name: "checkout-override-mode", Value: "from-job", - Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets pipeline/step env, hooks, plugins, and the Job API set checkout vars but blocks secrets. ′none′ additionally lets secrets set them. All mirror configuration and submodule clone config stay agent-authoritative in every mode. The checkout SSH key and Git LFS toggle are not governed by this flag and stay job-settable in every mode. Disabling command-eval forces this to ′strict′.", env.CheckoutOverrideModeNames), + Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets hooks, plugins, and the Job API set checkout vars, but blocks pipeline/step env and secrets. ′none′ additionally lets pipeline/step env and secrets set them. All mirror configuration and submodule clone config stay agent-authoritative in every mode. The checkout SSH key and Git LFS toggle are not governed by this flag and stay job-settable in every mode. Disabling command-eval forces this to ′strict′.", env.CheckoutOverrideModeNames), EnvVar: "BUILDKITE_CHECKOUT_OVERRIDE_MODE", } diff --git a/env/protected.go b/env/protected.go index e2693658bd..efe6e6890b 100644 --- a/env/protected.go +++ b/env/protected.go @@ -87,9 +87,10 @@ var protectedEnv = map[string]protection{ // checkoutOverrideScope contains checkout-related vars whose write-protection // depends on the checkout-override mode (see CheckoutOverrideMode). Under the -// default (from-job) the job may set them from pipeline/step env, hooks, plugins, -// and the Job API, overriding agent config, but secrets may not; strict locks -// them against every source; none leaves them fully open, including secrets. +// default (from-job) the job may set them from within-job sources (hooks, plugins, +// and the Job API), overriding agent config, but the backend job env (pipeline/ +// step env) and secrets may not; strict locks them against every source; none +// leaves them fully open, including the backend job env and secrets. // Locking matters because git is riddled with shell injections, so letting a job // set git flags would otherwise be a way to bypass protections like // no-command-eval (which is why disabling command-eval forces the mode to @@ -150,11 +151,10 @@ func IsCheckoutOverrideScoped(name string) bool { type CheckoutOverrideMode int const ( - // CheckoutOverrideFromJob is the default: the job may configure its own - // checkout from pipeline/step env, hooks, plugins, and the Job API, - // overriding agent config, but secrets may not set checkout-scoped vars. This - // is deliberately more permissive than the agent's historical behaviour, - // which locked checkout-scoped vars against backend job env. + // CheckoutOverrideFromJob is the default and matches the agent's historical + // behaviour: the job may configure its own checkout from within-job sources + // (hooks, plugins, and the Job API), overriding agent config, but the backend + // job env (pipeline/step env) and secrets may not set checkout-scoped vars. CheckoutOverrideFromJob CheckoutOverrideMode = iota // CheckoutOverrideStrict locks the checkoutOverrideScope vars against every @@ -223,11 +223,12 @@ func (m CheckoutOverrideMode) RestrictedForCommandEval(commandEvalEnabled bool) } // IsCheckoutLocked reports whether a checkout-scoped var is locked against the -// job's own configuration sources (backend job env, hooks, plugins, and the Job -// API) under the given mode. Only strict locks these; from-job and none let the -// job configure its own checkout. Secrets are governed separately by -// IsCheckoutLockedForSecrets, and vars that aren't checkout-scoped by IsProtected -// / IsProtectedFromWithinJob. +// job's within-job configuration sources (hooks, plugins, and the Job API) under +// the given mode. Only strict locks these; from-job and none let the job +// configure its own checkout. The backend job env (pipeline/step env) and secrets +// are external sources locked in every mode except none, governed separately (see +// IsCheckoutLockedForSecrets and createEnvironment in agent/job_runner.go); vars +// that aren't checkout-scoped are governed by IsProtected / IsProtectedFromWithinJob. func IsCheckoutLocked(name string, mode CheckoutOverrideMode) bool { if !IsCheckoutOverrideScoped(name) { return false @@ -238,8 +239,9 @@ func IsCheckoutLocked(name string, mode CheckoutOverrideMode) bool { // IsCheckoutLockedForSecrets reports whether a checkout-scoped var is locked // against secret-to-env mappings under the given mode. Secrets are an external // source, so both strict and from-job block them; only none lets a secret set -// checkout config. Vars that aren't checkout-scoped are governed by IsProtected -// instead. +// checkout config. The backend job env follows the same rule, enforced in +// createEnvironment (agent/job_runner.go). Vars that aren't checkout-scoped are +// governed by IsProtected instead. func IsCheckoutLockedForSecrets(name string, mode CheckoutOverrideMode) bool { if !IsCheckoutOverrideScoped(name) { return false diff --git a/env/protected_test.go b/env/protected_test.go index a45e962865..f0c35ec1fb 100644 --- a/env/protected_test.go +++ b/env/protected_test.go @@ -178,8 +178,11 @@ func TestCheckoutLockPredicates(t *testing.T) { ) // The matrix from the design: strict locks every source; from-job lets the - // job's own sources (backend env, hooks, plugins, Job API) set checkout vars - // but still blocks secrets; none locks nothing, including secrets. + // job's within-job sources (hooks, plugins, Job API) set checkout vars but + // blocks the backend job env and secrets; none locks nothing, including the + // backend job env and secrets. IsCheckoutLocked covers only the within-job + // sources; the backend job env shares the secrets rule (IsCheckoutLockedForSecrets + // and createEnvironment). cases := []struct { mode CheckoutOverrideMode wantJob bool From b0c51959d61ce599256d6bf43ebc44039c073c3b Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 15:04:24 -0400 Subject: [PATCH 52/57] Refresh executor config after applying secrets Secrets are written to e.shell.Env in setUp before any hook runs, but the checkout phase reads ExecutorConfig struct fields (GitCloneFlags, SkipCheckout, GitSparseCheckoutPaths) that are only refreshed from env when a hook triggers applyEnvironmentChanges. With no environment/plugin hook, a checkout-scoped var a secret is allowed to set under none mode never reached checkout. Refresh the config right after fetchAndSetSecrets so it takes effect, calling ReadFromEnvironment directly to avoid logging the value. --- internal/job/executor.go | 7 ++ .../integration/secrets_integration_test.go | 65 ++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/internal/job/executor.go b/internal/job/executor.go index 8bb807e094..62a4dc7407 100644 --- a/internal/job/executor.go +++ b/internal/job/executor.go @@ -928,6 +928,13 @@ func (e *Executor) setUp(ctx context.Context) (retErr error) { if err := e.fetchAndSetSecrets(ctx); err != nil { return fmt.Errorf("failed to fetch secrets for job: %w", err) } + // fetchAndSetSecrets only writes to e.shell.Env. Refresh the executor + // config so a checkout-scoped var a secret is allowed to set (none mode) + // reaches the checkout phase, which reads struct fields like e.GitCloneFlags + // rather than the env. With no environment/plugin hook nothing else triggers + // a refresh before checkout. We don't route through applyEnvironmentChanges + // because its change logging would print the secret value. + e.ReadFromEnvironment(e.shell.Env) } // It's important to do this before checking out plugins, in case you want diff --git a/internal/job/integration/secrets_integration_test.go b/internal/job/integration/secrets_integration_test.go index 9495348e4d..407bffd474 100644 --- a/internal/job/integration/secrets_integration_test.go +++ b/internal/job/integration/secrets_integration_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/buildkite/agent/v3/internal/job" "github.com/buildkite/agent/v3/jobapi" "github.com/buildkite/bintest/v3" "github.com/buildkite/go-pipeline" @@ -626,7 +627,10 @@ func TestSecretsIntegration_CheckoutOverrideMode(t *testing.T) { commandEvalDisabled bool wantErr bool }{ - {name: "none_allows_checkout_scoped_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", mode: "none"}, + // This case runs a real checkout, and under none the secret now actually + // reaches git clone (see TestSecretsIntegration_CheckoutScopedSecretReachesCheckout), + // so the value must be a checkout-safe clone flag rather than --mirror. + {name: "none_allows_checkout_scoped_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--verbose", mode: "none"}, {name: "default_rejects_checkout_scoped_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", wantErr: true}, {name: "from_job_rejects_checkout_scoped_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", mode: "from-job", wantErr: true}, {name: "strict_rejects_checkout_locked_secret", envVar: "BUILDKITE_GIT_CLONE_FLAGS", secretValue: "--mirror", mode: "strict", wantErr: true}, @@ -699,3 +703,62 @@ func TestSecretsIntegration_CheckoutOverrideMode(t *testing.T) { }) } } + +// TestSecretsIntegration_CheckoutScopedSecretReachesCheckout verifies that a +// checkout-scoped var set by an allowed secret (none mode) actually reaches the +// checkout phase, not just e.shell.Env. Secrets are applied in setUp before any +// hook runs, and checkout reads struct fields like e.GitCloneFlags that are only +// refreshed from env when a hook triggers applyEnvironmentChanges. With no +// environment/plugin hook, a secret-supplied BUILDKITE_GIT_CLONE_FLAGS would +// otherwise be silently ignored by git clone. No hooks are registered here so the +// clone flag having any effect depends on the config refresh after fetchAndSetSecrets. +func TestSecretsIntegration_CheckoutScopedSecretReachesCheckout(t *testing.T) { + t.Parallel() + + tester, err := NewExecutorTester(mainCtx) + if err != nil { + t.Fatalf("setting up executor tester: %v", err) + } + defer tester.Close() + + // --verbose is functionally identical to the default -v clone flag but a + // distinct string, so the assertion distinguishes the secret value reaching + // checkout from the stale default. + apiServer := setupSecretsAPIServer(t, map[string]string{"CLONE_FLAGS_SECRET": "--verbose"}) + defer apiServer.Close() + + secretsJSON, err := json.Marshal([]pipeline.Secret{{ + Key: "CLONE_FLAGS_SECRET", + EnvironmentVariable: "BUILDKITE_GIT_CLONE_FLAGS", + }}) + if err != nil { + t.Fatalf("marshaling secrets: %v", err) + } + + git := tester. + MustMock(t, "git"). + PassthroughToLocalCommand() + + // clone must use --verbose from the secret; the rest of the sequence uses the + // clean/fetch flags set below. + git.ExpectAll([][]any{ + {"clone", "--verbose", "--", tester.Repo.Path, "."}, + {"clean", "-fdq"}, + {"fetch", "-v", "--", "origin", "main"}, + {"-c", "advice.detachedHead=false", "checkout", "-f", "FETCH_HEAD"}, + {"clean", "-fdq"}, + {"--no-pager", "log", "-1", "HEAD", "-s", "--no-color", gitShowFormatArg}, + }) + + agent := tester.MockAgent(t) + agent.Expect("meta-data", "exists", job.CommitMetadataKey).AndExitWith(1) + agent.Expect("meta-data", "set", job.CommitMetadataKey).WithStdin(commitPattern) + + tester.RunAndCheck(t, + fmt.Sprintf("BUILDKITE_SECRETS_CONFIG=%s", string(secretsJSON)), + fmt.Sprintf("BUILDKITE_AGENT_ENDPOINT=%s", apiServer.URL), + "BUILDKITE_CHECKOUT_OVERRIDE_MODE=none", + "BUILDKITE_GIT_CLEAN_FLAGS=-fdq", + "BUILDKITE_GIT_FETCH_FLAGS=-v", + ) +} From 3ecf4aeeeca6a0c483637a22566554abb8af15b3 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 15:37:11 -0400 Subject: [PATCH 53/57] Keep from-job checkout timeout behavior matching main The checkout-override lock force-emitted the submodules, skip-checkout, skip-fetch, and checkout-timeout vars for both strict and from-job, so under the default from-job mode a pipeline/step env value on the agent's silent side was overwritten with the agent default. For the checkout timeout that meant an unset agent config forced 0 (no timeout), a backward-incompatible change to the default for a value that isn't a git-flag injection vector. Restrict the unconditional force-emit to strict; from-job and none keep the historical conditional emit. The checkout flags stay agent-authoritative under from-job as before. Add from-job silent-side coverage and update the mode docs. --- .../job_environment_integration_test.go | 61 +++++++++++++++++-- agent/job_runner.go | 21 ++++--- env/protected.go | 16 +++-- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/agent/integration/job_environment_integration_test.go b/agent/integration/job_environment_integration_test.go index 5081e95d23..c9ed64a977 100644 --- a/agent/integration/job_environment_integration_test.go +++ b/agent/integration/job_environment_integration_test.go @@ -298,10 +298,12 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { wantEnvValue: "--mirror", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CLONE_FLAGS"}, }, - // The four vars below use the conditional-emit branch in createEnvironment - // (a separate code path from setCheckoutEnv). Pin that from-job takes the - // locking branch for them, so agent config wins over backend job env, - // matching strict. + // The four vars below are emitted by the agent only on the non-default side + // of each (submodules off, skip-checkout on, skip-fetch on, timeout > 0). On + // that emitted side, from-job keeps agent config authoritative over backend + // job env (via setCheckoutEnv). The silent-side counterparts, where the agent + // stays at its default and a job value survives under from-job (matching main; + // only strict force-emits there), are covered further below. { name: "from_job_locks_submodules_to_agent_config", varName: "BUILDKITE_GIT_SUBMODULES", @@ -354,6 +356,57 @@ func TestCheckoutScopedJobEnvOverrideHonorsCheckoutOverrideMode(t *testing.T) { wantEnvValue: "60", wantIgnoredEnvVars: []string{"BUILDKITE_GIT_CHECKOUT_TIMEOUT"}, }, + // Silent side under from-job: the agent leaves each var at its default and + // stays quiet, so a backend job env value survives, matching historical + // behaviour. Only strict force-emits the default here (see the strict_*_off + // cases below); from-job must not, hence no ignored vars. + { + name: "from_job_allows_job_env_checkout_timeout_when_agent_unset", + varName: "BUILDKITE_GIT_CHECKOUT_TIMEOUT", + jobEnv: map[string]string{ + "BUILDKITE_GIT_CHECKOUT_TIMEOUT": "99", + }, + agentCfg: agent.AgentConfiguration{ + CheckoutOverrideMode: env.CheckoutOverrideFromJob, + }, + wantEnvValue: "99", + }, + { + name: "from_job_allows_job_env_submodules_when_agent_default_on", + varName: "BUILDKITE_GIT_SUBMODULES", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SUBMODULES": "false", + }, + agentCfg: agent.AgentConfiguration{ + GitSubmodules: true, + CheckoutOverrideMode: env.CheckoutOverrideFromJob, + }, + wantEnvValue: "false", + }, + { + name: "from_job_allows_job_env_skip_checkout_when_agent_default_off", + varName: "BUILDKITE_SKIP_CHECKOUT", + jobEnv: map[string]string{ + "BUILDKITE_SKIP_CHECKOUT": "true", + }, + agentCfg: agent.AgentConfiguration{ + SkipCheckout: false, + CheckoutOverrideMode: env.CheckoutOverrideFromJob, + }, + wantEnvValue: "true", + }, + { + name: "from_job_allows_job_env_skip_fetch_existing_commits_when_agent_default_off", + varName: "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", + jobEnv: map[string]string{ + "BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS": "true", + }, + agentCfg: agent.AgentConfiguration{ + GitSkipFetchExistingCommits: false, + CheckoutOverrideMode: env.CheckoutOverrideFromJob, + }, + wantEnvValue: "true", + }, { name: "none_allows_job_env_to_enable_submodules", varName: "BUILDKITE_GIT_SUBMODULES", diff --git a/agent/job_runner.go b/agent/job_runner.go index ba6429a4cd..b87154d7ad 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -618,21 +618,26 @@ BUILDKITE_AGENT_JWKS_KEY_ID` setEnv("BUILDKITE_ADDITIONAL_HOOKS_PATHS", strings.Join(r.conf.AgentConfiguration.AdditionalHooksPaths, ",")) setEnv("BUILDKITE_PLUGINS_PATH", r.conf.AgentConfiguration.PluginsPath) setEnv("BUILDKITE_SSH_KEYSCAN", fmt.Sprint(r.conf.AgentConfiguration.SSHKeyscan)) - // These three vars are only emitted by the agent on one side of their - // default (submodules off, skip-checkout on, skip-fetch on); on the other - // side the agent stays silent and lets pipeline/step env decide. That silent - // side would leak past the checkout-override lock, so when the lock is on we - // emit the agent value unconditionally to keep agent config authoritative. - if checkoutLockedFromJobEnv { + // submodules/skip-checkout/skip-fetch/timeout are each emitted by the agent on + // only one side of their default (submodules off, skip-checkout on, skip-fetch + // on, timeout > 0); on the other, silent, side the agent stays quiet and + // historically let pipeline/step env decide. Only strict closes that silent + // side, emitting the agent value unconditionally so a job can't reintroduce a + // setting the agent left at its default. from-job and none keep the historical + // conditional emit; setCheckoutEnv then decides precedence against backend job + // env: from-job keeps agent config authoritative on the emitted side, none lets + // job env win. The checkout flags above are agent-authoritative in both from-job + // and strict (they were always emitted), so only these four differ by mode here. + if checkoutMode == envutil.CheckoutOverrideStrict { setEnv("BUILDKITE_GIT_SUBMODULES", fmt.Sprint(r.conf.AgentConfiguration.GitSubmodules)) setEnv("BUILDKITE_SKIP_CHECKOUT", fmt.Sprint(r.conf.AgentConfiguration.SkipCheckout)) setEnv("BUILDKITE_GIT_SKIP_FETCH_EXISTING_COMMITS", fmt.Sprint(r.conf.AgentConfiguration.GitSkipFetchExistingCommits)) - // A zero timeout means no checkout timeout; emit it anyway when locked so + // A zero timeout means no checkout timeout; emit it anyway under strict so // a job-supplied value can't reintroduce one past the agent config. setEnv("BUILDKITE_GIT_CHECKOUT_TIMEOUT", strconv.Itoa(r.conf.AgentConfiguration.GitCheckoutTimeout)) } else { // Default submodules off when disabled in agent config, but let pipeline/ - // step env override via BUILDKITE_GIT_SUBMODULES. + // step env override via BUILDKITE_GIT_SUBMODULES (unless from-job locks it). if !r.conf.AgentConfiguration.GitSubmodules { setCheckoutEnv("BUILDKITE_GIT_SUBMODULES", "false") } diff --git a/env/protected.go b/env/protected.go index efe6e6890b..fd3f5d1492 100644 --- a/env/protected.go +++ b/env/protected.go @@ -153,8 +153,13 @@ type CheckoutOverrideMode int const ( // CheckoutOverrideFromJob is the default and matches the agent's historical // behaviour: the job may configure its own checkout from within-job sources - // (hooks, plugins, and the Job API), overriding agent config, but the backend - // job env (pipeline/step env) and secrets may not set checkout-scoped vars. + // (hooks, plugins, and the Job API), overriding agent config. The backend job + // env (pipeline/step env) and secrets may not override the checkout flags, + // which the agent always emits. The submodules/skip-checkout/skip-fetch/timeout + // toggles are emitted by the agent only on their non-default side, so backend + // job env can still set those when the agent leaves them at their default, as + // on main (secrets are blocked from all of them; see IsCheckoutLockedForSecrets). + // strict closes that toggle gap. CheckoutOverrideFromJob CheckoutOverrideMode = iota // CheckoutOverrideStrict locks the checkoutOverrideScope vars against every @@ -239,8 +244,11 @@ func IsCheckoutLocked(name string, mode CheckoutOverrideMode) bool { // IsCheckoutLockedForSecrets reports whether a checkout-scoped var is locked // against secret-to-env mappings under the given mode. Secrets are an external // source, so both strict and from-job block them; only none lets a secret set -// checkout config. The backend job env follows the same rule, enforced in -// createEnvironment (agent/job_runner.go). Vars that aren't checkout-scoped are +// checkout config. The backend job env is mostly the same (enforced in +// createEnvironment, agent/job_runner.go), except that under from-job it still +// lets pipeline/step env set the submodules/skip-checkout/skip-fetch/timeout +// toggles on their default side to match historical behaviour; secrets have no +// such history, so they stay blocked. Vars that aren't checkout-scoped are // governed by IsProtected instead. func IsCheckoutLockedForSecrets(name string, mode CheckoutOverrideMode) bool { if !IsCheckoutOverrideScoped(name) { From f0de23dd1ecb17253423a1ca507993450ac5ad6e Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 16:05:19 -0400 Subject: [PATCH 54/57] Don't log raw env values on parse errors ReadFromEnvironment runs over the full shell env, which now includes secret-backed values (setUp refreshes config right after fetching secrets). The bool/int/slice parse-error branches logged the raw value via the stdlib logger, which bypasses the shell redactors, so a malformed secret-backed value (e.g. an unparseable BUILDKITE_GIT_CHECKOUT_TIMEOUT, or BUILDKITE_GIT_LFS_ENABLED which any source may set) could leak to the job log. Log only the var name (and type) in those warnings. --- internal/job/config.go | 8 +++++--- internal/job/config_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/internal/job/config.go b/internal/job/config.go index 15eac7bdea..fa5970f5d1 100644 --- a/internal/job/config.go +++ b/internal/job/config.go @@ -271,7 +271,9 @@ func (c *ExecutorConfig) ReadFromEnvironment(environ *env.Environment) map[strin case reflect.Bool: newBool, err := strconv.ParseBool(newStr) if err != nil { - log.Printf("warning: cannot parse %s=%q as bool, ignoring", tag, newStr) + // Don't log the value: this env may hold secret-backed values + // (see setUp in executor.go) and this logger bypasses redaction. + log.Printf("warning: cannot parse %s as bool, ignoring", tag) break } if newBool == v.Bool() { @@ -283,7 +285,7 @@ func (c *ExecutorConfig) ReadFromEnvironment(environ *env.Environment) map[strin case reflect.Int: newInt, err := strconv.Atoi(newStr) if err != nil { - log.Printf("warning: cannot parse %s=%q as int, ignoring", tag, newStr) + log.Printf("warning: cannot parse %s as int, ignoring", tag) break } if int64(newInt) == v.Int() { @@ -294,7 +296,7 @@ func (c *ExecutorConfig) ReadFromEnvironment(environ *env.Environment) map[strin case reflect.Slice: if v.Type().Elem() != reflect.TypeFor[string]() { - log.Printf("warning: cannot parse %s=%q as %v, ignoring", tag, newStr, v.Type()) + log.Printf("warning: cannot parse %s as %v, ignoring", tag, v.Type()) break } var newSlice []string diff --git a/internal/job/config_test.go b/internal/job/config_test.go index 9ebf03e717..4a3ca6d5b6 100644 --- a/internal/job/config_test.go +++ b/internal/job/config_test.go @@ -1,6 +1,9 @@ package job import ( + "bytes" + "log" + "os" "slices" "strings" "testing" @@ -112,6 +115,34 @@ func TestReadFromEnvironmentIgnoresMalformedBooleans(t *testing.T) { } } +// ReadFromEnvironment runs over the full shell env, which can include +// secret-backed values (setUp refreshes config right after fetching secrets). +// Malformed bool/int values must not be echoed to the standard logger, which +// writes outside the shell's redactors. Not parallel: it swaps the global log +// output. +func TestReadFromEnvironmentDoesNotLogMalformedValues(t *testing.T) { + const secret = "s3cret-not-a-number" + + var buf bytes.Buffer + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(os.Stderr) }) + + config := &ExecutorConfig{} + environ := env.FromSlice([]string{ + "BUILDKITE_GIT_CHECKOUT_TIMEOUT=" + secret, // int field + "BUILDKITE_GIT_LFS_ENABLED=" + secret, // bool field + }) + config.ReadFromEnvironment(environ) + + if strings.Contains(buf.String(), secret) { + t.Errorf("log leaked a secret-backed value: %q", buf.String()) + } + // The var name should still be logged so the warning stays actionable. + if !strings.Contains(buf.String(), "BUILDKITE_GIT_CHECKOUT_TIMEOUT") { + t.Errorf("expected warning to name the offending var, got %q", buf.String()) + } +} + func TestReadFromEnvironmentDoesNotRefreshCheckoutOverrideMode(t *testing.T) { t.Parallel() From 3d6fafcae0930e13017ea7095556ef7c2ae2352f Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 16:23:17 -0400 Subject: [PATCH 55/57] Set LFS skip-smudge after secret-driven config refresh GIT_LFS_SKIP_SMUDGE was set in setUp before secrets were fetched and the executor config refreshed, so a secret enabling BUILDKITE_GIT_LFS_ENABLED (it's outside both protection maps, so any source may set it) turned on the LFS checkout path without skip-smudge. The plain git checkout would then pull LFS objects through the automatic smudge filter instead of the controlled LFS fetch/checkout path, bypassing sparse scoping. Move the skip-smudge decision to after the secret refresh so it sees the secret-enabled LFS. --- internal/job/executor.go | 14 +++--- .../integration/secrets_integration_test.go | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/internal/job/executor.go b/internal/job/executor.go index 62a4dc7407..37cd4bb05d 100644 --- a/internal/job/executor.go +++ b/internal/job/executor.go @@ -917,12 +917,6 @@ func (e *Executor) setUp(ctx context.Context) (retErr error) { // Disable any interactive Git/SSH prompting e.shell.Env.Set("GIT_TERMINAL_PROMPT", "0") - // Suppress automatic LFS smudge only when LFS is enabled, so the checkout - // phase can materialise objects explicitly; otherwise git's default applies. - if e.GitLFSEnabled { - e.shell.Env.Set("GIT_LFS_SKIP_SMUDGE", "1") - } - // Fetch and set secrets before environment hook execution if e.Secrets != "" { if err := e.fetchAndSetSecrets(ctx); err != nil { @@ -937,6 +931,14 @@ func (e *Executor) setUp(ctx context.Context) (retErr error) { e.ReadFromEnvironment(e.shell.Env) } + // Suppress automatic LFS smudge only when LFS is enabled, so the checkout + // phase can materialise objects explicitly; otherwise git's default applies. + // After the secret refresh above so a secret that enables LFS is honoured here + // rather than leaving smudge on for the plain checkout. + if e.GitLFSEnabled { + e.shell.Env.Set("GIT_LFS_SKIP_SMUDGE", "1") + } + // It's important to do this before checking out plugins, in case you want // to use the global environment hook to whitelist the plugins that are // allowed to be used. diff --git a/internal/job/integration/secrets_integration_test.go b/internal/job/integration/secrets_integration_test.go index 407bffd474..617351deb1 100644 --- a/internal/job/integration/secrets_integration_test.go +++ b/internal/job/integration/secrets_integration_test.go @@ -762,3 +762,51 @@ func TestSecretsIntegration_CheckoutScopedSecretReachesCheckout(t *testing.T) { "BUILDKITE_GIT_FETCH_FLAGS=-v", ) } + +// BUILDKITE_GIT_LFS_ENABLED sits outside both protection maps, so a secret can +// enable LFS in any mode. GIT_LFS_SKIP_SMUDGE must then be set before checkout so +// LFS objects go through the controlled fetch/checkout path rather than the +// automatic smudge filter. The skip-smudge decision runs after the secret refresh +// in setUp, so it sees the secret-enabled LFS. The environment hook is the last +// setUp step before checkout, so it observes the final env; it exits non-zero to +// stop before the real LFS checkout flow (which needs git-lfs installed). +func TestSecretsIntegration_SecretEnabledLFSSetsSkipSmudge(t *testing.T) { + t.Parallel() + + tester, err := NewExecutorTester(mainCtx) + if err != nil { + t.Fatalf("setting up executor tester: %v", err) + } + defer tester.Close() + + apiServer := setupSecretsAPIServer(t, map[string]string{"LFS_TOGGLE": "true"}) + defer apiServer.Close() + + secretsJSON, err := json.Marshal([]pipeline.Secret{{ + Key: "LFS_TOGGLE", + EnvironmentVariable: "BUILDKITE_GIT_LFS_ENABLED", + }}) + if err != nil { + t.Fatalf("marshaling secrets: %v", err) + } + + tester.ExpectGlobalHook("environment").AndCallFunc(func(c *bintest.Call) { + // Report only the non-secret marker, not the secret-backed LFS value. + _, _ = fmt.Fprintf(c.Stderr, "SKIP_SMUDGE=%q\n", c.GetEnv("GIT_LFS_SKIP_SMUDGE")) + c.Exit(1) + }) + + // Agent LFS defaults off, so a skip-smudge of "1" can only come from the secret + // enabling LFS before the (post-refresh) skip-smudge block. + err = tester.Run(t, + fmt.Sprintf("BUILDKITE_SECRETS_CONFIG=%s", string(secretsJSON)), + fmt.Sprintf("BUILDKITE_AGENT_ENDPOINT=%s", apiServer.URL), + ) + if err == nil { + t.Fatalf("expected job to stop at the environment hook, but it succeeded. Full output: %s", tester.Output) + } + + if !strings.Contains(tester.Output, `SKIP_SMUDGE="1"`) { + t.Fatalf("expected GIT_LFS_SKIP_SMUDGE=1 before checkout after a secret enabled LFS, got: %s", tester.Output) + } +} From 6462e552ce09d6cbd520c560584e355e0383669d Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 16:40:25 -0400 Subject: [PATCH 56/57] Clarify from-job checkout-override help text The default from-job mode blocks secrets and keeps the checkout flags authoritative over pipeline/step env, but still lets pipeline/step env set the checkout timeout, submodules, skip-checkout, and skip-fetch-existing-commits toggles the agent leaves unset (matching earlier agent behaviour). Spell out that carve-out so the default isn't read as a complete backend-env lock. --- clicommand/global.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clicommand/global.go b/clicommand/global.go index 93f8a61b21..ffa78c1856 100644 --- a/clicommand/global.go +++ b/clicommand/global.go @@ -209,7 +209,7 @@ var ( CheckoutOverrideModeFlag = cli.StringFlag{ Name: "checkout-override-mode", Value: "from-job", - Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets hooks, plugins, and the Job API set checkout vars, but blocks pipeline/step env and secrets. ′none′ additionally lets pipeline/step env and secrets set them. All mirror configuration and submodule clone config stay agent-authoritative in every mode. The checkout SSH key and Git LFS toggle are not governed by this flag and stay job-settable in every mode. Disabling command-eval forces this to ′strict′.", env.CheckoutOverrideModeNames), + Usage: fmt.Sprintf("Controls which sources may override the agent's checkout settings; one of %v. ′strict′ makes the agent authoritative against pipeline/step env, secrets, hooks, plugins, and the Job API. ′from-job′ (default) lets hooks, plugins, and the Job API set checkout vars, blocks secrets, and keeps the agent's checkout flags authoritative over pipeline/step env; pipeline/step env may still set the checkout timeout, submodules, skip-checkout, and skip-fetch-existing-commits toggles that the agent leaves unset, matching earlier agent behaviour. ′none′ additionally lets pipeline/step env and secrets set them. All mirror configuration and submodule clone config stay agent-authoritative in every mode. The checkout SSH key and Git LFS toggle are not governed by this flag and stay job-settable in every mode. Disabling command-eval forces this to ′strict′.", env.CheckoutOverrideModeNames), EnvVar: "BUILDKITE_CHECKOUT_OVERRIDE_MODE", } From 34de3375ff71f1ddee9e2d782a78480b67ced8b3 Mon Sep 17 00:00:00 2001 From: Pete Tomasik Date: Wed, 15 Jul 2026 16:40:33 -0400 Subject: [PATCH 57/57] Set LFS skip-smudge when a hook enables LFS mid-job GIT_LFS_SKIP_SMUDGE is set in setUp, but a hook, plugin, or Job API call that enables BUILDKITE_GIT_LFS_ENABLED afterward flips GitLFSEnabled via ReadFromEnvironment without re-applying skip-smudge, so the plain checkout would pull LFS objects through the automatic smudge filter instead of the controlled fetch/checkout path. Re-apply skip-smudge in applyEnvironmentChanges when LFS is enabled, covering within-job sources; setUp still handles the agent-config and secret paths. --- internal/job/executor.go | 8 ++++ .../job/integration/hooks_integration_test.go | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/internal/job/executor.go b/internal/job/executor.go index 37cd4bb05d..1b2f03567d 100644 --- a/internal/job/executor.go +++ b/internal/job/executor.go @@ -666,6 +666,14 @@ func (e *Executor) applyEnvironmentChanges(changes hook.EnvChanges) { // Note this func mutates/refreshes the ExecutorConfig too. executorConfigEnvChanges := e.ReadFromEnvironment(e.shell.Env) + // If a hook, plugin, or the Job API just enabled LFS, ensure GIT_LFS_SKIP_SMUDGE + // is set before checkout so LFS objects go through the controlled fetch/checkout + // path rather than the automatic smudge filter. setUp handles the agent-config + // and secret paths; this covers within-job sources that flip it afterward. + if e.GitLFSEnabled { + e.shell.Env.Set("GIT_LFS_SKIP_SMUDGE", "1") + } + // Print out the env vars that changed. As we go through each // one, we'll determine if it was a special environment variable // that has changed the executor configuration at runtime. diff --git a/internal/job/integration/hooks_integration_test.go b/internal/job/integration/hooks_integration_test.go index bbc7240e30..fca181a718 100644 --- a/internal/job/integration/hooks_integration_test.go +++ b/internal/job/integration/hooks_integration_test.go @@ -320,6 +320,52 @@ func TestEnvironmentHookCannotRelaxCheckoutOverrideMode(t *testing.T) { } } +func TestEnvironmentHookEnablingLFSSetsSkipSmudge(t *testing.T) { + t.Parallel() + + // A hook that enables LFS after setUp's skip-smudge decision must still get + // GIT_LFS_SKIP_SMUDGE set before checkout, so LFS objects go through the + // controlled fetch/checkout path rather than the automatic smudge filter. The + // hook also skips checkout so the command hook can assert without git-lfs + // installed (from-job lets a hook set both vars). + tester, err := NewExecutorTester(mainCtx) + if err != nil { + t.Fatalf("NewExecutorTester() error = %v", err) + } + defer tester.Close() + + filename := "environment" + script := []string{ + "#!/usr/bin/env bash", + "export BUILDKITE_GIT_LFS_ENABLED=true", + "export BUILDKITE_SKIP_CHECKOUT=true", + } + if runtime.GOOS == "windows" { + filename = "environment.bat" + script = []string{ + "@echo off", + "set BUILDKITE_GIT_LFS_ENABLED=true", + "set BUILDKITE_SKIP_CHECKOUT=true", + } + } + + if err := os.WriteFile(filepath.Join(tester.HooksDir, filename), []byte(strings.Join(script, "\n")), 0o700); err != nil { + t.Fatalf("os.WriteFile(%q, script, 0o700) = %v", filename, err) + } + + tester.ExpectGlobalHook("command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + if got := c.GetEnv("GIT_LFS_SKIP_SMUDGE"); got != "1" { + _, _ = fmt.Fprintf(c.Stderr, "GIT_LFS_SKIP_SMUDGE=%q, want \"1\" after a hook enabled LFS\n", got) + c.Exit(1) + return + } + c.Exit(0) + }) + + // Agent LFS defaults off; the hook is the only thing enabling it. + tester.RunAndCheck(t) +} + func TestNoCommandEvalFloorsCheckoutOverrideModeToStrict(t *testing.T) { t.Parallel()