Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions pkg/workflow/safe_outputs_config_generation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions pkg/workflow/safe_outputs_config_generation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down