diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index b25fd9b0ae5..b5e13bf2027 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -33,13 +33,21 @@ func generateSafeOutputsConfig(data *WorkflowData) (string, error) { safeOutputsConfigLog.Print("Generating safe outputs configuration for workflow") safeOutputsConfig := make(map[string]any) + engineManifestFiles, engineManifestPathPrefixes := getEngineAgentFileInfoFromWorkflowData(data) // Standard handler configs — sourced from handlerRegistry (same as GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG) for handlerName, builder := range handlerRegistry { if handlerCfg := builder(data.SafeOutputs); handlerCfg != nil { + excludeFiles := extractStringSliceFromConfig(handlerCfg, "_protected_files_exclude") // Strip the internal sentinel key used by the handler manager for compile-time // exclusion processing — it must not be forwarded to the runtime config.json. delete(handlerCfg, "_protected_files_exclude") + if _, hasProtectedFiles := handlerCfg["protected_files"]; hasProtectedFiles { + fullManifestFiles := getAllManifestFiles(engineManifestFiles...) + fullPathPrefixes := getProtectedPathPrefixes(engineManifestPathPrefixes...) + handlerCfg["protected_files"] = excludeFromSlice(fullManifestFiles, excludeFiles...) + handlerCfg["protected_path_prefixes"] = excludeFromSlice(fullPathPrefixes, excludeFiles...) + } safeOutputsConfig[handlerName] = handlerCfg } } @@ -195,6 +203,29 @@ func generateSafeOutputsConfig(data *WorkflowData) (string, error) { return string(configJSON), nil } +func getEngineAgentFileInfoFromWorkflowData(data *WorkflowData) (manifestFiles []string, pathPrefixes []string) { + if data == nil || data.EngineConfig == nil { + return nil, nil + } + + engineRegistry := GetGlobalEngineRegistry() + engine, err := engineRegistry.GetEngine(data.EngineConfig.ID) + if err != nil { + safeOutputsConfigLog.Printf("Engine lookup failed for %q: %v — skipping agent manifest file injection", data.EngineConfig.ID, err) + return nil, nil + } + if engine == nil { + return nil, nil + } + + provider, ok := engine.(AgentFileProvider) + if !ok { + return nil, nil + } + + return provider.GetAgentManifestFiles(), provider.GetAgentManifestPathPrefixes() +} + // generateCustomJobToolDefinition creates an MCP tool definition for a custom safe-output job. // Returns a map representing the tool definition in MCP format with name, description, and inputSchema. func generateCustomJobToolDefinition(jobName string, jobConfig *SafeJobConfig) map[string]any { diff --git a/pkg/workflow/safe_outputs_config_generation_test.go b/pkg/workflow/safe_outputs_config_generation_test.go index 61e4aa36a66..c6f36a6697f 100644 --- a/pkg/workflow/safe_outputs_config_generation_test.go +++ b/pkg/workflow/safe_outputs_config_generation_test.go @@ -520,6 +520,64 @@ func TestGenerateSafeOutputsConfigCreatePullRequestBackwardCompat(t *testing.T) assert.False(t, hasAllowedRepos, "allowed_repos should not be present when not configured") } +func TestGenerateSafeOutputsConfigCreatePullRequestIncludesEngineManifests(t *testing.T) { + data := &WorkflowData{ + EngineConfig: &EngineConfig{ID: "claude"}, + SafeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + }, + }, + } + + result, err := generateSafeOutputsConfig(data) + require.NoError(t, err, "generateSafeOutputsConfig should not return an error") + require.NotEmpty(t, result, "Expected non-empty config") + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(result), &parsed), "Result must be valid JSON") + + prConfig, ok := parsed["create_pull_request"].(map[string]any) + require.True(t, ok, "Expected create_pull_request key in config") + + protectedFiles := parseStringSliceAny(prConfig["protected_files"], nil) + assert.Contains(t, protectedFiles, "CLAUDE.md", "CLAUDE.md should be protected for Claude engine workflows") + assert.Contains(t, protectedFiles, "AGENTS.md", "AGENTS.md should be protected for Claude engine workflows") + + protectedPathPrefixes := parseStringSliceAny(prConfig["protected_path_prefixes"], nil) + assert.Contains(t, protectedPathPrefixes, ".claude/", ".claude/ should be protected for Claude engine workflows") +} + +func TestGenerateSafeOutputsConfigCreatePullRequestAppliesProtectedFilesExclude(t *testing.T) { + data := &WorkflowData{ + EngineConfig: &EngineConfig{ID: "claude"}, + SafeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + ProtectedFilesExclude: []string{"CLAUDE.md", ".claude/"}, + }, + }, + } + + result, err := generateSafeOutputsConfig(data) + require.NoError(t, err, "generateSafeOutputsConfig should not return an error") + require.NotEmpty(t, result, "Expected non-empty config") + + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(result), &parsed), "Result must be valid JSON") + + prConfig, ok := parsed["create_pull_request"].(map[string]any) + require.True(t, ok, "Expected create_pull_request key in config") + + protectedFiles := parseStringSliceAny(prConfig["protected_files"], nil) + assert.NotContains(t, protectedFiles, "CLAUDE.md", "CLAUDE.md should be excluded from protected_files") + assert.Contains(t, protectedFiles, "AGENTS.md", "AGENTS.md should remain in protected_files") + + protectedPathPrefixes := parseStringSliceAny(prConfig["protected_path_prefixes"], nil) + assert.NotContains(t, protectedPathPrefixes, ".claude/", ".claude/ should be excluded from protected_path_prefixes") + assert.Contains(t, protectedPathPrefixes, ".github/", ".github/ should remain protected") +} + // TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssue tests that auto_close_issue // is correctly serialized into config.json for create_pull_request. func TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssue(t *testing.T) {