diff --git a/packages/go/config/load.go b/packages/go/config/load.go index 3d71edf5..aec20054 100644 --- a/packages/go/config/load.go +++ b/packages/go/config/load.go @@ -71,6 +71,9 @@ func LoadEdge(cfgFile string) (*EdgeConfig, error) { } if v.InConfig("execution_presets") { raw := v.Get("execution_presets") + if err := rejectNullSingleRequestTemplatePaths(raw); err != nil { + return nil, err + } var presets []ExecutionPreset var metadata mapstructure.Metadata decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ @@ -681,6 +684,42 @@ func validateWorkspaceNumericLimits(ws WorkspaceDefinition, nodeIdx, wsIdx int) return nil } +// rejectNullSingleRequestTemplatePaths fails closed on a present-but-null +// plan_file/review_file before the strict preset decode runs. Mapstructure +// collapses a present raw nil into the same nil pointer as an absent key, so +// `plan_file: null` and `review_file: ~` would otherwise be indistinguishable +// from omission and silently select the built-in default. Only a truly absent +// key may reach the nil-pointer fallback in resolveSingleRequestTemplates; +// non-nil values stay with the strict decoder and loadTemplateFile, and any +// other raw shape is left to the strict decoder's own type error. +func rejectNullSingleRequestTemplatePaths(raw any) error { + presets, ok := raw.([]any) + if !ok { + return nil + } + for i, entry := range presets { + preset, ok := entry.(map[string]any) + if !ok { + continue + } + singleRequest, ok := preset["single_request"].(map[string]any) + if !ok { + continue + } + templates, ok := singleRequest["templates"].(map[string]any) + if !ok { + continue + } + for _, field := range []string{"plan_file", "review_file"} { + value, present := templates[field] + if present && value == nil { + return fmt.Errorf("execution_presets[%d] single_request.templates.%s: template path must not be empty", i, field) + } + } + } + return nil +} + // resolveSingleRequestTemplates freezes the effective Plan/Review templates for // every single-request preset. Only an omitted plan_file/review_file selects the // built-in default; every configured value is routed through loadTemplateFile so diff --git a/packages/go/config/model_execution_preset_config_test.go b/packages/go/config/model_execution_preset_config_test.go index ab5d8991..60669e76 100644 --- a/packages/go/config/model_execution_preset_config_test.go +++ b/packages/go/config/model_execution_preset_config_test.go @@ -965,9 +965,11 @@ nodes: }) // Only an omitted field selects the built-in default. An explicitly - // configured empty or whitespace-only path is a configuration error and - // must be rejected with its own field context before the loader touches - // the filesystem. + // configured empty, whitespace-only, or null path is a configuration error + // and must be rejected with its own field context before the loader touches + // the filesystem. Both YAML null spellings are covered because a present + // null decodes into the same nil pointer as an absent key, so the rejection + // has to happen on the raw preset structure before that presence is erased. t.Run("explicitly configured empty template path fails closed", func(t *testing.T) { cases := []struct { name string @@ -977,8 +979,12 @@ nodes: }{ {"empty plan_file", `""`, "", "single_request.templates.plan_file"}, {"whitespace plan_file", `" "`, "", "single_request.templates.plan_file"}, + {"null plan_file", "null", "", "single_request.templates.plan_file"}, + {"tilde plan_file", "~", "", "single_request.templates.plan_file"}, {"empty review_file", "", `""`, "single_request.templates.review_file"}, {"whitespace review_file", "", `" "`, "single_request.templates.review_file"}, + {"null review_file", "", "null", "single_request.templates.review_file"}, + {"tilde review_file", "", "~", "single_request.templates.review_file"}, } for _, tc := range cases {