diff --git a/actions/setup/js/json_object_to_markdown.cjs b/actions/setup/js/json_object_to_markdown.cjs index bd8f2e4c98..6af6136099 100644 --- a/actions/setup/js/json_object_to_markdown.cjs +++ b/actions/setup/js/json_object_to_markdown.cjs @@ -39,7 +39,7 @@ function formatValue(value) { * Convert a plain JavaScript object to Markdown bullet points. * Nested objects and arrays are rendered as indented sub-lists. * - * @param {Record} obj - The object to render + * @param {object} obj - The object to render * @param {number} [depth=0] - Current indentation depth * @returns {string} - Markdown bullet list string */ @@ -60,7 +60,7 @@ function jsonObjectToMarkdown(obj, depth = 0) { lines.push(`${indent}- **${label}**:`); for (const item of value) { if (typeof item === "object" && item !== null) { - lines.push(jsonObjectToMarkdown(/** @type {Record} */ item, depth + 1)); + lines.push(jsonObjectToMarkdown(item, depth + 1)); } else { lines.push(`${" ".repeat(depth + 1)}- ${String(item)}`); } @@ -68,7 +68,7 @@ function jsonObjectToMarkdown(obj, depth = 0) { } } else if (typeof value === "object" && value !== null) { lines.push(`${indent}- **${label}**:`); - lines.push(jsonObjectToMarkdown(/** @type {Record} */ value, depth + 1)); + lines.push(jsonObjectToMarkdown(value, depth + 1)); } else { const formatted = formatValue(value); lines.push(`${indent}- **${label}**: ${formatted}`); diff --git a/pkg/workflow/claude_mcp.go b/pkg/workflow/claude_mcp.go index 9e52a1f2e0..111c8185cb 100644 --- a/pkg/workflow/claude_mcp.go +++ b/pkg/workflow/claude_mcp.go @@ -16,11 +16,12 @@ func (e *ClaudeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a // Claude uses JSON format without Copilot-specific fields and multi-line args createRenderer := func(isLast bool) *MCPConfigRendererUnified { return NewMCPConfigRenderer(MCPRendererOptions{ - IncludeCopilotFields: false, // Claude doesn't use "type" and "tools" fields - InlineArgs: false, // Claude uses multi-line args format - Format: "json", - IsLast: isLast, - ActionMode: GetActionModeFromWorkflowData(workflowData), + IncludeCopilotFields: false, // Claude doesn't use "type" and "tools" fields + InlineArgs: false, // Claude uses multi-line args format + Format: "json", + IsLast: isLast, + ActionMode: GetActionModeFromWorkflowData(workflowData), + WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData), }) } @@ -59,7 +60,7 @@ func (e *ClaudeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData) }, RenderWebFetch: func(yaml *strings.Builder, isLast bool) { - renderMCPFetchServerConfig(yaml, "json", " ", isLast, false) + renderMCPFetchServerConfig(yaml, "json", " ", isLast, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData)) }, RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error { return e.renderClaudeMCPConfigWithContext(yaml, toolName, toolConfig, isLast, workflowData) diff --git a/pkg/workflow/codex_mcp.go b/pkg/workflow/codex_mcp.go index 965a7b73b5..2e25c1675b 100644 --- a/pkg/workflow/codex_mcp.go +++ b/pkg/workflow/codex_mcp.go @@ -19,11 +19,12 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an // Codex uses TOML format without Copilot-specific fields and multi-line args createRenderer := func(isLast bool) *MCPConfigRendererUnified { return NewMCPConfigRenderer(MCPRendererOptions{ - IncludeCopilotFields: false, // Codex doesn't use "type" and "tools" fields - InlineArgs: false, // Codex uses multi-line args format - Format: "toml", - IsLast: isLast, - ActionMode: GetActionModeFromWorkflowData(workflowData), + IncludeCopilotFields: false, // Codex doesn't use "type" and "tools" fields + InlineArgs: false, // Codex uses multi-line args format + Format: "toml", + IsLast: isLast, + ActionMode: GetActionModeFromWorkflowData(workflowData), + WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData), }) } @@ -69,7 +70,7 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an renderer.RenderMCPScriptsMCP(yaml, workflowData.MCPScripts, workflowData) } case "web-fetch": - renderMCPFetchServerConfig(yaml, "toml", " ", false, false) + renderMCPFetchServerConfig(yaml, "toml", " ", false, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData)) default: // Handle custom MCP tools using shared helper (with adapter for isLast parameter) HandleCustomMCPToolInSwitch(yaml, toolName, expandedTools, false, func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error { @@ -112,11 +113,12 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an actionMode = workflowData.ActionMode } return NewMCPConfigRenderer(MCPRendererOptions{ - IncludeCopilotFields: false, // Gateway doesn't need Copilot fields - InlineArgs: false, // Use standard multi-line format - Format: "json", - IsLast: isLast, - ActionMode: actionMode, + IncludeCopilotFields: false, // Gateway doesn't need Copilot fields + InlineArgs: false, // Use standard multi-line format + Format: "json", + IsLast: isLast, + ActionMode: actionMode, + WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData), }) } @@ -152,7 +154,7 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData) }, RenderWebFetch: func(yaml *strings.Builder, isLast bool) { - renderMCPFetchServerConfig(yaml, "json", " ", isLast, false) + renderMCPFetchServerConfig(yaml, "json", " ", isLast, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData)) }, RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error { return e.renderCodexJSONMCPConfigWithContext(yaml, toolName, toolConfig, isLast, workflowData) @@ -177,6 +179,7 @@ func (e *CodexEngine) renderCodexMCPConfigWithContext(yaml *strings.Builder, too IndentLevel: " ", Format: "toml", RewriteLocalhostToDocker: rewriteLocalhost, + GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData), } err := renderSharedMCPConfig(yaml, toolName, toolConfig, renderer) @@ -200,6 +203,7 @@ func (e *CodexEngine) renderCodexJSONMCPConfigWithContext(yaml *strings.Builder, Format: "json", IndentLevel: " ", RewriteLocalhostToDocker: rewriteLocalhost, + GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData), } yaml.WriteString(" \"" + toolName + "\": {\n") diff --git a/pkg/workflow/copilot_mcp.go b/pkg/workflow/copilot_mcp.go index 1efae1f2df..e794944ba7 100644 --- a/pkg/workflow/copilot_mcp.go +++ b/pkg/workflow/copilot_mcp.go @@ -19,11 +19,12 @@ func (e *CopilotEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string] // Copilot uses JSON format with type and tools fields, and inline args createRenderer := func(isLast bool) *MCPConfigRendererUnified { return NewMCPConfigRenderer(MCPRendererOptions{ - IncludeCopilotFields: true, // Copilot uses "type" and "tools" fields - InlineArgs: true, // Copilot uses inline args format - Format: "json", - IsLast: isLast, - ActionMode: GetActionModeFromWorkflowData(workflowData), + IncludeCopilotFields: true, // Copilot uses "type" and "tools" fields + InlineArgs: true, // Copilot uses inline args format + Format: "json", + IsLast: isLast, + ActionMode: GetActionModeFromWorkflowData(workflowData), + WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData), }) } @@ -64,7 +65,7 @@ func (e *CopilotEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string] renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData) }, RenderWebFetch: func(yaml *strings.Builder, isLast bool) { - renderMCPFetchServerConfig(yaml, "json", " ", isLast, true) + renderMCPFetchServerConfig(yaml, "json", " ", isLast, true, deriveWriteSinkGuardPolicyFromWorkflow(workflowData)) }, RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error { return e.renderCopilotMCPConfigWithContext(yaml, toolName, toolConfig, isLast, workflowData) @@ -96,6 +97,7 @@ func (e *CopilotEngine) renderCopilotMCPConfigWithContext(yaml *strings.Builder, IndentLevel: " ", RequiresCopilotFields: true, RewriteLocalhostToDocker: rewriteLocalhost, + GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData), } yaml.WriteString(" \"" + toolName + "\": {\n") diff --git a/pkg/workflow/fetch.go b/pkg/workflow/fetch.go index b92d2c8a8d..334c90974e 100644 --- a/pkg/workflow/fetch.go +++ b/pkg/workflow/fetch.go @@ -53,7 +53,8 @@ func AddMCPFetchServerIfNeeded(tools map[string]any, engine CodingAgentEngine) ( // renderMCPFetchServerConfig renders the MCP fetch server configuration // This is a shared function that can be used by all engines // includeTools parameter adds "tools": ["*"] field for engines that require it (e.g., Copilot) -func renderMCPFetchServerConfig(yaml *strings.Builder, format string, indent string, isLast bool, includeTools bool) { +// guardPolicies parameter adds write-sink guard policies when derived from the GitHub guard-policy +func renderMCPFetchServerConfig(yaml *strings.Builder, format string, indent string, isLast bool, includeTools bool, guardPolicies map[string]any) { fetchLog.Printf("Rendering MCP fetch server config: format=%s, includeTools=%v", format, includeTools) switch format { @@ -61,7 +62,12 @@ func renderMCPFetchServerConfig(yaml *strings.Builder, format string, indent str // JSON format (for Claude, Copilot, Custom engines) // Use container key per MCP Gateway schema (container-based stdio server) yaml.WriteString(indent + "\"web-fetch\": {\n") - yaml.WriteString(indent + " \"container\": \"mcp/fetch\"\n") + if len(guardPolicies) > 0 { + yaml.WriteString(indent + " \"container\": \"mcp/fetch\",\n") + renderGuardPoliciesJSON(yaml, guardPolicies, indent+" ") + } else { + yaml.WriteString(indent + " \"container\": \"mcp/fetch\"\n") + } if isLast { yaml.WriteString(indent + "}\n") } else { @@ -73,5 +79,9 @@ func renderMCPFetchServerConfig(yaml *strings.Builder, format string, indent str yaml.WriteString(indent + "\n") yaml.WriteString(indent + "[mcp_servers.\"web-fetch\"]\n") yaml.WriteString(indent + "container = \"mcp/fetch\"\n") + // Add guard policies as a separate TOML section if configured + if len(guardPolicies) > 0 { + renderGuardPoliciesToml(yaml, guardPolicies, "web-fetch") + } } } diff --git a/pkg/workflow/fetch_test.go b/pkg/workflow/fetch_test.go index 8a12f10311..79d5dee153 100644 --- a/pkg/workflow/fetch_test.go +++ b/pkg/workflow/fetch_test.go @@ -188,7 +188,7 @@ func TestRenderMCPFetchServerConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var yaml strings.Builder - renderMCPFetchServerConfig(&yaml, tt.format, tt.indent, tt.isLast, tt.includeTools) + renderMCPFetchServerConfig(&yaml, tt.format, tt.indent, tt.isLast, tt.includeTools, nil) output := yaml.String() for _, substr := range tt.expectSubstr { diff --git a/pkg/workflow/gemini_mcp.go b/pkg/workflow/gemini_mcp.go index b33a7204c9..89e0494dc8 100644 --- a/pkg/workflow/gemini_mcp.go +++ b/pkg/workflow/gemini_mcp.go @@ -15,11 +15,12 @@ func (e *GeminiEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a // Create unified renderer with Gemini-specific options createRenderer := func(isLast bool) *MCPConfigRendererUnified { return NewMCPConfigRenderer(MCPRendererOptions{ - IncludeCopilotFields: false, - InlineArgs: false, - Format: "json", // Gemini uses JSON format like Claude/Codex - IsLast: isLast, - ActionMode: GetActionModeFromWorkflowData(workflowData), + IncludeCopilotFields: false, + InlineArgs: false, + Format: "json", // Gemini uses JSON format like Claude/Codex + IsLast: isLast, + ActionMode: GetActionModeFromWorkflowData(workflowData), + WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData), }) } @@ -54,7 +55,7 @@ func (e *GeminiEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData) }, RenderWebFetch: func(yaml *strings.Builder, isLast bool) { - renderMCPFetchServerConfig(yaml, "json", " ", isLast, false) + renderMCPFetchServerConfig(yaml, "json", " ", isLast, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData)) }, RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error { return renderCustomMCPConfigWrapperWithContext(yaml, toolName, toolConfig, isLast, workflowData) diff --git a/pkg/workflow/mcp_config_builtin.go b/pkg/workflow/mcp_config_builtin.go index 7f42d4e5ce..0bf99def32 100644 --- a/pkg/workflow/mcp_config_builtin.go +++ b/pkg/workflow/mcp_config_builtin.go @@ -172,7 +172,7 @@ func renderSafeOutputsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, i // renderAgenticWorkflowsMCPConfigWithOptions generates the Agentic Workflows MCP server configuration with engine-specific options // Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. // Uses MCP Gateway spec format: container, entrypoint, entrypointArgs, and mounts fields. -func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, actionMode ActionMode) { +func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, actionMode ActionMode, guardPolicies map[string]any) { mcpBuiltinLog.Printf("Rendering Agentic Workflows MCP config: isLast=%v, includeCopilotFields=%v, actionMode=%v", isLast, includeCopilotFields, actionMode) // Environment variables: map of env var name to value (literal) or source variable (reference) @@ -288,7 +288,13 @@ func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bo yaml.WriteString(" \"" + envVar.name + "\": \"" + valueStr + "\"" + comma + "\n") } - yaml.WriteString(" }\n") + // Close env section - with or without trailing comma depending on whether guard policies follow + if len(guardPolicies) > 0 { + yaml.WriteString(" },\n") + renderGuardPoliciesJSON(yaml, guardPolicies, " ") + } else { + yaml.WriteString(" }\n") + } if isLast { yaml.WriteString(" }\n") diff --git a/pkg/workflow/mcp_config_comprehensive_test.go b/pkg/workflow/mcp_config_comprehensive_test.go index eb03f12a41..e521c5b4e5 100644 --- a/pkg/workflow/mcp_config_comprehensive_test.go +++ b/pkg/workflow/mcp_config_comprehensive_test.go @@ -653,7 +653,7 @@ func TestRenderSerenaMCPConfigWithOptions(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var output strings.Builder - renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs) + renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs, nil) result := output.String() @@ -1153,7 +1153,7 @@ func TestRenderSerenaMCPConfigDockerMode(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var output strings.Builder - renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs) + renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs, nil) result := output.String() diff --git a/pkg/workflow/mcp_config_custom.go b/pkg/workflow/mcp_config_custom.go index 319347aa27..e8ce9d208b 100644 --- a/pkg/workflow/mcp_config_custom.go +++ b/pkg/workflow/mcp_config_custom.go @@ -31,6 +31,7 @@ func renderCustomMCPConfigWrapperWithContext(yaml *strings.Builder, toolName str IndentLevel: " ", Format: "json", RewriteLocalhostToDocker: rewriteLocalhost, + GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData), } err := renderSharedMCPConfig(yaml, toolName, toolConfig, renderer) @@ -181,9 +182,14 @@ func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig ma return nil } + // When guard policies are present in JSON format, they become the actual last field. + // The last existing property must have a trailing comma to allow appending guard policies. + hasTrailingGuardPolicies := renderer.Format == "json" && len(renderer.GuardPolicies) > 0 + // Render properties based on format for propIndex, property := range existingProperties { - isLast := propIndex == len(existingProperties)-1 + // In JSON format, if guard policies follow, the last existing property is no longer "last" + isLast := (propIndex == len(existingProperties)-1) && !hasTrailingGuardPolicies switch property { case "type": @@ -497,6 +503,15 @@ func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig ma } } + // Render guard policies after all properties + if hasTrailingGuardPolicies { + // JSON format: guard policies are the last field inside the server object + renderGuardPoliciesJSON(yaml, renderer.GuardPolicies, renderer.IndentLevel) + } else if renderer.Format == "toml" && len(renderer.GuardPolicies) > 0 { + // TOML format: guard policies are a separate TOML section after the server config + renderGuardPoliciesToml(yaml, renderer.GuardPolicies, toolName) + } + return nil } diff --git a/pkg/workflow/mcp_config_playwright_renderer.go b/pkg/workflow/mcp_config_playwright_renderer.go index 9bec9c325c..195e8f7270 100644 --- a/pkg/workflow/mcp_config_playwright_renderer.go +++ b/pkg/workflow/mcp_config_playwright_renderer.go @@ -68,7 +68,7 @@ var mcpPlaywrightLog = logger.New("workflow:mcp_config_playwright_renderer") // renderPlaywrightMCPConfigWithOptions generates the Playwright MCP server configuration with engine-specific options // Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. // Uses MCP Gateway spec format: container, entrypointArgs, mounts, and args fields. -func renderPlaywrightMCPConfigWithOptions(yaml *strings.Builder, playwrightConfig *PlaywrightToolConfig, isLast bool, includeCopilotFields bool, inlineArgs bool) { +func renderPlaywrightMCPConfigWithOptions(yaml *strings.Builder, playwrightConfig *PlaywrightToolConfig, isLast bool, includeCopilotFields bool, inlineArgs bool, guardPolicies map[string]any) { mcpPlaywrightLog.Printf("Rendering Playwright MCP config options: copilot_fields=%t, inline_args=%t", includeCopilotFields, inlineArgs) customArgs := getPlaywrightCustomArgs(playwrightConfig) @@ -155,7 +155,13 @@ func renderPlaywrightMCPConfigWithOptions(yaml *strings.Builder, playwrightConfi } // Add volume mounts - yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"]\n") + // When guard policies follow, mounts is not the last field (add trailing comma) + if len(guardPolicies) > 0 { + yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"],\n") + renderGuardPoliciesJSON(yaml, guardPolicies, " ") + } else { + yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"]\n") + } // Note: tools field is NOT included here - the converter script adds it back // for Copilot. This keeps the gateway config compatible with the schema. diff --git a/pkg/workflow/mcp_config_refactor_test.go b/pkg/workflow/mcp_config_refactor_test.go index cfa8bc6df6..013c9f93ca 100644 --- a/pkg/workflow/mcp_config_refactor_test.go +++ b/pkg/workflow/mcp_config_refactor_test.go @@ -186,7 +186,7 @@ func TestRenderAgenticWorkflowsMCPConfigWithOptions(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var output strings.Builder - renderAgenticWorkflowsMCPConfigWithOptions(&output, tt.isLast, tt.includeCopilotFields, tt.actionMode) + renderAgenticWorkflowsMCPConfigWithOptions(&output, tt.isLast, tt.includeCopilotFields, tt.actionMode, nil) result := output.String() diff --git a/pkg/workflow/mcp_config_serena_renderer.go b/pkg/workflow/mcp_config_serena_renderer.go index ba01a01fa1..861dd3c0da 100644 --- a/pkg/workflow/mcp_config_serena_renderer.go +++ b/pkg/workflow/mcp_config_serena_renderer.go @@ -86,7 +86,7 @@ func selectSerenaContainer(serenaTool any) string { // renderSerenaMCPConfigWithOptions generates the Serena MCP server configuration with engine-specific options // Uses Docker container with stdio transport (ghcr.io/github/serena-mcp-server:latest) -func renderSerenaMCPConfigWithOptions(yaml *strings.Builder, serenaTool any, isLast bool, includeCopilotFields bool, inlineArgs bool) { +func renderSerenaMCPConfigWithOptions(yaml *strings.Builder, serenaTool any, isLast bool, includeCopilotFields bool, inlineArgs bool, guardPolicies map[string]any) { customArgs := getSerenaCustomArgs(serenaTool) yaml.WriteString(" \"serena\": {\n") @@ -136,7 +136,13 @@ func renderSerenaMCPConfigWithOptions(yaml *strings.Builder, serenaTool any, isL // Add volume mount for workspace access // Security: Use GITHUB_WORKSPACE environment variable instead of template expansion to prevent template injection - yaml.WriteString(" \"mounts\": [\"\\${GITHUB_WORKSPACE}:\\${GITHUB_WORKSPACE}:rw\"]\n") + // When guard policies follow, mounts is not the last field (add trailing comma) + if len(guardPolicies) > 0 { + yaml.WriteString(" \"mounts\": [\"\\${GITHUB_WORKSPACE}:\\${GITHUB_WORKSPACE}:rw\"],\n") + renderGuardPoliciesJSON(yaml, guardPolicies, " ") + } else { + yaml.WriteString(" \"mounts\": [\"\\${GITHUB_WORKSPACE}:\\${GITHUB_WORKSPACE}:rw\"]\n") + } // Note: tools field is NOT included here - the converter script adds it back // for Copilot. This keeps the gateway config compatible with the schema. diff --git a/pkg/workflow/mcp_config_types.go b/pkg/workflow/mcp_config_types.go index 39646e97b4..dfcc111261 100644 --- a/pkg/workflow/mcp_config_types.go +++ b/pkg/workflow/mcp_config_types.go @@ -47,6 +47,11 @@ type MCPConfigRenderer struct { // RewriteLocalhostToDocker indicates if localhost URLs should be rewritten to host.docker.internal // This is needed when the agent runs inside a firewall container and needs to access MCP servers on the host RewriteLocalhostToDocker bool + // GuardPolicies contains the write-sink guard policies to render at the end of the MCP server configuration. + // For JSON format, they are added as the last field inside the server object. + // For TOML format, they are added as a separate TOML section after the server config. + // Nil when no guard policies should be applied. + GuardPolicies map[string]any } // ToolConfig represents a tool configuration interface for type safety diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index d92dd7c5c0..bf1007a943 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -343,6 +343,21 @@ func transformRepoPattern(pattern string) string { return "private:" + pattern } +// deriveWriteSinkGuardPolicyFromWorkflow derives a write-sink guard policy for non-GitHub MCP servers +// from the workflow's GitHub guard-policy configuration. This uses the same derivation as +// deriveSafeOutputsGuardPolicyFromGitHub, ensuring that as guard policies are rolled out, only +// GitHub inputs are filtered while outputs to non-GitHub servers are not restricted. +// Returns nil when no GitHub guard policies are configured or when workflowData is nil. +func deriveWriteSinkGuardPolicyFromWorkflow(workflowData *WorkflowData) map[string]any { + if workflowData == nil || workflowData.Tools == nil { + return nil + } + if githubTool, hasGitHub := workflowData.Tools["github"]; hasGitHub { + return deriveSafeOutputsGuardPolicyFromGitHub(githubTool) + } + return nil +} + func getGitHubDockerImageVersion(githubTool any) string { githubDockerImageVersion := string(constants.DefaultGitHubMCPServerVersion) // Default Docker image version // Extract version setting from tool properties diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go index 20456d81ea..9bef856361 100644 --- a/pkg/workflow/mcp_renderer_builtin.go +++ b/pkg/workflow/mcp_renderer_builtin.go @@ -19,11 +19,16 @@ func (r *MCPConfigRendererUnified) RenderPlaywrightMCP(yaml *strings.Builder, pl if r.options.Format == "toml" { r.renderPlaywrightTOML(yaml, playwrightConfig) + // Add guard policies for TOML format as a separate section + if len(r.options.WriteSinkGuardPolicies) > 0 { + mcpRendererLog.Print("Adding guard-policies to playwright TOML (derived from GitHub guard-policy)") + renderGuardPoliciesToml(yaml, r.options.WriteSinkGuardPolicies, "playwright") + } return } // JSON format - renderPlaywrightMCPConfigWithOptions(yaml, playwrightConfig, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs) + renderPlaywrightMCPConfigWithOptions(yaml, playwrightConfig, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs, r.options.WriteSinkGuardPolicies) } // renderPlaywrightTOML generates Playwright MCP configuration in TOML format @@ -74,11 +79,16 @@ func (r *MCPConfigRendererUnified) RenderSerenaMCP(yaml *strings.Builder, serena if r.options.Format == "toml" { r.renderSerenaTOML(yaml, serenaTool) + // Add guard policies for TOML format as a separate section + if len(r.options.WriteSinkGuardPolicies) > 0 { + mcpRendererLog.Print("Adding guard-policies to serena TOML (derived from GitHub guard-policy)") + renderGuardPoliciesToml(yaml, r.options.WriteSinkGuardPolicies, "serena") + } return } // JSON format - renderSerenaMCPConfigWithOptions(yaml, serenaTool, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs) + renderSerenaMCPConfigWithOptions(yaml, serenaTool, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs, r.options.WriteSinkGuardPolicies) } // renderSerenaTOML generates Serena MCP configuration in TOML format @@ -180,11 +190,16 @@ func (r *MCPConfigRendererUnified) RenderMCPScriptsMCP(yaml *strings.Builder, mc if r.options.Format == "toml" { r.renderMCPScriptsTOML(yaml, mcpScripts, workflowData) + // Add guard policies for TOML format as a separate section + if len(r.options.WriteSinkGuardPolicies) > 0 { + mcpRendererLog.Print("Adding guard-policies to mcp-scripts TOML (derived from GitHub guard-policy)") + renderGuardPoliciesToml(yaml, r.options.WriteSinkGuardPolicies, constants.MCPScriptsMCPServerID.String()) + } return } // JSON format - renderMCPScriptsMCPConfigWithOptions(yaml, mcpScripts, r.options.IsLast, r.options.IncludeCopilotFields, workflowData) + renderMCPScriptsMCPConfigWithOptions(yaml, mcpScripts, r.options.IsLast, r.options.IncludeCopilotFields, workflowData, r.options.WriteSinkGuardPolicies) } // renderMCPScriptsTOML generates MCP Scripts configuration in TOML format @@ -216,11 +231,16 @@ func (r *MCPConfigRendererUnified) RenderAgenticWorkflowsMCP(yaml *strings.Build if r.options.Format == "toml" { r.renderAgenticWorkflowsTOML(yaml) + // Add guard policies for TOML format as a separate section + if len(r.options.WriteSinkGuardPolicies) > 0 { + mcpRendererLog.Print("Adding guard-policies to agentic-workflows TOML (derived from GitHub guard-policy)") + renderGuardPoliciesToml(yaml, r.options.WriteSinkGuardPolicies, constants.AgenticWorkflowsMCPServerID.String()) + } return } // JSON format - renderAgenticWorkflowsMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.ActionMode) + renderAgenticWorkflowsMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.ActionMode, r.options.WriteSinkGuardPolicies) } // renderAgenticWorkflowsTOML generates Agentic Workflows MCP configuration in TOML format diff --git a/pkg/workflow/mcp_renderer_types.go b/pkg/workflow/mcp_renderer_types.go index de0b74aa45..0bcece2e40 100644 --- a/pkg/workflow/mcp_renderer_types.go +++ b/pkg/workflow/mcp_renderer_types.go @@ -14,6 +14,11 @@ type MCPRendererOptions struct { IsLast bool // ActionMode indicates the action mode for workflow compilation (dev, release, script) ActionMode ActionMode + // WriteSinkGuardPolicies contains the write-sink guard policies to apply to non-GitHub MCP servers. + // These are derived from the GitHub guard-policy configuration and applied as a default write-sink + // to ensure that as guard policies are rolled out, only GitHub inputs are filtered while outputs + // to non-GitHub servers are not restricted. Nil when no GitHub guard policies are configured. + WriteSinkGuardPolicies map[string]any } // MCPConfigRendererUnified provides unified rendering methods for MCP configurations diff --git a/pkg/workflow/mcp_scripts_renderer.go b/pkg/workflow/mcp_scripts_renderer.go index fe6b45754d..35ec71e26a 100644 --- a/pkg/workflow/mcp_scripts_renderer.go +++ b/pkg/workflow/mcp_scripts_renderer.go @@ -48,7 +48,7 @@ func collectMCPScriptsSecrets(mcpScripts *MCPScriptsConfig) map[string]string { // renderMCPScriptsMCPConfigWithOptions generates the MCP Scripts server configuration with engine-specific options // Always uses HTTP transport mode -func renderMCPScriptsMCPConfigWithOptions(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, isLast bool, includeCopilotFields bool, workflowData *WorkflowData) { +func renderMCPScriptsMCPConfigWithOptions(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, isLast bool, includeCopilotFields bool, workflowData *WorkflowData, guardPolicies map[string]any) { mcpScriptsRendererLog.Printf("Rendering MCP Scripts config: includeCopilotFields=%t, isLast=%t", includeCopilotFields, isLast) @@ -80,10 +80,15 @@ func renderMCPScriptsMCPConfigWithOptions(yaml *strings.Builder, mcpScripts *MCP // Claude/Custom format: direct shell variable reference yaml.WriteString(" \"Authorization\": \"$GH_AW_MCP_SCRIPTS_API_KEY\"\n") } - // Close headers - no trailing comma since this is the last field + // Close headers - with or without trailing comma depending on whether guard policies follow // Note: env block is NOT included for HTTP servers because the old MCP Gateway schema // doesn't allow env in httpServerConfig. The variables are resolved via URL templates. - yaml.WriteString(" }\n") + if len(guardPolicies) > 0 { + yaml.WriteString(" },\n") + renderGuardPoliciesJSON(yaml, guardPolicies, " ") + } else { + yaml.WriteString(" }\n") + } if isLast { yaml.WriteString(" }\n") diff --git a/pkg/workflow/non_github_mcp_guard_policy_test.go b/pkg/workflow/non_github_mcp_guard_policy_test.go new file mode 100644 index 0000000000..a395349245 --- /dev/null +++ b/pkg/workflow/non_github_mcp_guard_policy_test.go @@ -0,0 +1,431 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDeriveWriteSinkGuardPolicyFromWorkflow tests the helper that derives guard policies from workflow data +func TestDeriveWriteSinkGuardPolicyFromWorkflow(t *testing.T) { + tests := []struct { + name string + workflowData *WorkflowData + expectNil bool + description string + expectedKey string + }{ + { + name: "nil workflow data", + workflowData: nil, + expectNil: true, + description: "nil workflowData should return nil", + }, + { + name: "nil tools", + workflowData: &WorkflowData{}, + expectNil: true, + description: "no tools should return nil", + }, + { + name: "no github tool", + workflowData: &WorkflowData{ + Tools: map[string]any{ + "playwright": map[string]any{}, + }, + }, + expectNil: true, + description: "no github tool means no guard policy", + }, + { + name: "github tool without guard policy", + workflowData: &WorkflowData{ + Tools: map[string]any{ + "github": map[string]any{ + "toolsets": []string{"default"}, + }, + }, + }, + expectNil: true, + description: "github tool without repos/min-integrity has no guard policy", + }, + { + name: "github tool with repos=all", + workflowData: &WorkflowData{ + Tools: map[string]any{ + "github": map[string]any{ + "repos": "all", + "min-integrity": "none", + }, + }, + }, + expectNil: false, + expectedKey: "write-sink", + description: "github guard policy with repos=all should produce write-sink policy", + }, + { + name: "github tool with specific repo", + workflowData: &WorkflowData{ + Tools: map[string]any{ + "github": map[string]any{ + "repos": "myorg/myrepo", + "min-integrity": "approved", + }, + }, + }, + expectNil: false, + expectedKey: "write-sink", + description: "github guard policy with specific repo should produce write-sink policy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := deriveWriteSinkGuardPolicyFromWorkflow(tt.workflowData) + if tt.expectNil { + assert.Nil(t, result, "Expected nil result for: %s", tt.description) + } else { + require.NotNil(t, result, "Expected non-nil result for: %s", tt.description) + assert.Contains(t, result, tt.expectedKey, "Expected write-sink key in policies for: %s", tt.description) + } + }) + } +} + +// TestRenderSharedMCPConfigWithGuardPoliciesJSON tests that guard policies are rendered correctly in JSON format +func TestRenderCustomToolWithGuardPoliciesJSON(t *testing.T) { + guardPolicies := map[string]any{ + "write-sink": map[string]any{ + "accept": []string{"*"}, + }, + } + + toolConfig := map[string]any{ + "url": "https://example.com/mcp", + } + + var output strings.Builder + renderer := MCPConfigRenderer{ + IndentLevel: " ", + Format: "json", + GuardPolicies: guardPolicies, + } + + err := renderSharedMCPConfig(&output, "my-tool", toolConfig, renderer) + require.NoError(t, err, "renderSharedMCPConfig should succeed") + + result := output.String() + // The url field should have a trailing comma (guard policies follow) + assert.Contains(t, result, "\"url\": \"https://example.com/mcp\",", "url field should have trailing comma") + // Guard policies should be rendered + assert.Contains(t, result, "\"guard-policies\"", "guard-policies should be rendered") + assert.Contains(t, result, "\"write-sink\"", "write-sink should be rendered") + assert.Contains(t, result, "\"accept\"", "accept should be rendered") +} + +// TestRenderSharedMCPConfigWithGuardPoliciesTOML tests that guard policies are rendered correctly in TOML format +func TestRenderCustomToolWithGuardPoliciesTOML(t *testing.T) { + guardPolicies := map[string]any{ + "write-sink": map[string]any{ + "accept": []string{"private:myorg/myrepo"}, + }, + } + + toolConfig := map[string]any{ + "url": "https://example.com/mcp", + } + + var output strings.Builder + renderer := MCPConfigRenderer{ + IndentLevel: " ", + Format: "toml", + GuardPolicies: guardPolicies, + } + + err := renderSharedMCPConfig(&output, "my-tool", toolConfig, renderer) + require.NoError(t, err, "renderSharedMCPConfig should succeed") + + result := output.String() + // TOML guard policies are in separate sections + assert.Contains(t, result, "[mcp_servers.my-tool.\"guard-policies\"]", "TOML guard-policies section should be present") + assert.Contains(t, result, "write-sink", "write-sink should be rendered") + assert.Contains(t, result, "accept", "accept should be rendered") + assert.Contains(t, result, "\"private:myorg/myrepo\"", "accept pattern should be rendered") +} + +// TestRenderSharedMCPConfigWithoutGuardPoliciesJSON tests that when no guard policies are set, no comma is added +func TestRenderCustomToolWithoutGuardPoliciesJSON(t *testing.T) { + toolConfig := map[string]any{ + "url": "https://example.com/mcp", + } + + var output strings.Builder + renderer := MCPConfigRenderer{ + IndentLevel: " ", + Format: "json", + // No GuardPolicies set + } + + err := renderSharedMCPConfig(&output, "my-tool", toolConfig, renderer) + require.NoError(t, err, "renderSharedMCPConfig should succeed") + + result := output.String() + // The url field should NOT have a trailing comma (it's the last field) + assert.NotContains(t, result, "\"url\": \"https://example.com/mcp\",", "url field should not have trailing comma") + // No guard policies + assert.NotContains(t, result, "guard-policies", "guard-policies should not be rendered") +} + +// TestPlaywrightMCPWithGuardPoliciesJSON tests that playwright gets write-sink guard policies in JSON format +func TestPlaywrightMCPWithGuardPoliciesJSON(t *testing.T) { + guardPolicies := map[string]any{ + "write-sink": map[string]any{ + "accept": []string{"*"}, + }, + } + + var output strings.Builder + renderPlaywrightMCPConfigWithOptions(&output, nil, true, false, false, guardPolicies) + + result := output.String() + assert.Contains(t, result, "\"guard-policies\"", "playwright should have guard-policies in JSON") + assert.Contains(t, result, "\"write-sink\"", "playwright should have write-sink in JSON") +} + +// TestPlaywrightMCPWithoutGuardPoliciesJSON tests that playwright without guard policies is unchanged +func TestPlaywrightMCPWithoutGuardPoliciesJSON(t *testing.T) { + var output strings.Builder + renderPlaywrightMCPConfigWithOptions(&output, nil, true, false, false, nil) + + result := output.String() + assert.NotContains(t, result, "guard-policies", "playwright without guard policies should not have guard-policies") +} + +// TestSerenaMCPWithGuardPoliciesJSON tests that serena gets write-sink guard policies in JSON format +func TestSerenaMCPWithGuardPoliciesJSON(t *testing.T) { + guardPolicies := map[string]any{ + "write-sink": map[string]any{ + "accept": []string{"private:myorg"}, + }, + } + + var output strings.Builder + renderSerenaMCPConfigWithOptions(&output, nil, true, false, false, guardPolicies) + + result := output.String() + assert.Contains(t, result, "\"guard-policies\"", "serena should have guard-policies in JSON") + assert.Contains(t, result, "\"write-sink\"", "serena should have write-sink in JSON") + assert.Contains(t, result, "\"private:myorg\"", "serena should have accept pattern") +} + +// TestMCPScriptsMCPWithGuardPoliciesJSON tests that mcp-scripts gets write-sink guard policies in JSON format +func TestMCPScriptsMCPWithGuardPoliciesJSON(t *testing.T) { + guardPolicies := map[string]any{ + "write-sink": map[string]any{ + "accept": []string{"*"}, + }, + } + + var output strings.Builder + renderMCPScriptsMCPConfigWithOptions(&output, nil, true, false, nil, guardPolicies) + + result := output.String() + assert.Contains(t, result, "\"guard-policies\"", "mcp-scripts should have guard-policies in JSON") + assert.Contains(t, result, "\"write-sink\"", "mcp-scripts should have write-sink in JSON") + // The headers section should have a trailing comma + assert.Contains(t, result, "},\n", "headers closing brace should have trailing comma when guard policies follow") +} + +// TestAgenticWorkflowsMCPWithGuardPoliciesJSON tests that agentic-workflows gets write-sink guard policies in JSON format +func TestAgenticWorkflowsMCPWithGuardPoliciesJSON(t *testing.T) { + guardPolicies := map[string]any{ + "write-sink": map[string]any{ + "accept": []string{"*"}, + }, + } + + var output strings.Builder + renderAgenticWorkflowsMCPConfigWithOptions(&output, true, false, ActionModeRelease, guardPolicies) + + result := output.String() + assert.Contains(t, result, "\"guard-policies\"", "agentic-workflows should have guard-policies in JSON") + assert.Contains(t, result, "\"write-sink\"", "agentic-workflows should have write-sink in JSON") +} + +// TestWebFetchMCPWithGuardPoliciesJSON tests that web-fetch gets write-sink guard policies in JSON format +func TestWebFetchMCPWithGuardPoliciesJSON(t *testing.T) { + guardPolicies := map[string]any{ + "write-sink": map[string]any{ + "accept": []string{"*"}, + }, + } + + var output strings.Builder + renderMCPFetchServerConfig(&output, "json", " ", true, false, guardPolicies) + + result := output.String() + assert.Contains(t, result, "\"guard-policies\"", "web-fetch should have guard-policies in JSON") + assert.Contains(t, result, "\"write-sink\"", "web-fetch should have write-sink in JSON") + // container should have trailing comma + assert.Contains(t, result, "\"container\": \"mcp/fetch\",", "container field should have trailing comma when guard policies follow") +} + +// TestWebFetchMCPWithGuardPoliciesTOML tests that web-fetch gets write-sink guard policies in TOML format +func TestWebFetchMCPWithGuardPoliciesTOML(t *testing.T) { + guardPolicies := map[string]any{ + "write-sink": map[string]any{ + "accept": []string{"*"}, + }, + } + + var output strings.Builder + renderMCPFetchServerConfig(&output, "toml", " ", false, false, guardPolicies) + + result := output.String() + assert.Contains(t, result, "guard-policies", "web-fetch TOML should have guard-policies section") + assert.Contains(t, result, "write-sink", "web-fetch TOML should have write-sink") + assert.Contains(t, result, "accept", "web-fetch TOML should have accept") +} + +// TestAllNonGitHubMCPServersGetGuardPoliciesViaRenderer tests that the MCPConfigRendererUnified +// propagates WriteSinkGuardPolicies to all non-GitHub MCP server render methods +func TestAllNonGitHubMCPServersGetGuardPoliciesViaRenderer(t *testing.T) { + guardPolicies := map[string]any{ + "write-sink": map[string]any{ + "accept": []string{"*"}, + }, + } + + t.Run("playwright JSON", func(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "json", + IsLast: true, + WriteSinkGuardPolicies: guardPolicies, + }) + var output strings.Builder + renderer.RenderPlaywrightMCP(&output, nil) + assert.Contains(t, output.String(), "guard-policies", "playwright JSON should have guard-policies") + }) + + t.Run("playwright TOML", func(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "toml", + WriteSinkGuardPolicies: guardPolicies, + }) + var output strings.Builder + renderer.RenderPlaywrightMCP(&output, nil) + assert.Contains(t, output.String(), "[mcp_servers.playwright.\"guard-policies\"]", "playwright TOML should have guard-policies section") + }) + + t.Run("serena JSON", func(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "json", + IsLast: true, + WriteSinkGuardPolicies: guardPolicies, + }) + var output strings.Builder + renderer.RenderSerenaMCP(&output, nil) + assert.Contains(t, output.String(), "guard-policies", "serena JSON should have guard-policies") + }) + + t.Run("serena TOML", func(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "toml", + WriteSinkGuardPolicies: guardPolicies, + }) + var output strings.Builder + renderer.RenderSerenaMCP(&output, nil) + assert.Contains(t, output.String(), "[mcp_servers.serena.\"guard-policies\"]", "serena TOML should have guard-policies section") + }) + + t.Run("agentic-workflows JSON", func(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "json", + IsLast: true, + WriteSinkGuardPolicies: guardPolicies, + }) + var output strings.Builder + renderer.RenderAgenticWorkflowsMCP(&output) + assert.Contains(t, output.String(), "guard-policies", "agentic-workflows JSON should have guard-policies") + }) + + t.Run("agentic-workflows TOML", func(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "toml", + WriteSinkGuardPolicies: guardPolicies, + }) + var output strings.Builder + renderer.RenderAgenticWorkflowsMCP(&output) + result := output.String() + // The TOML section ID for agentic-workflows uses the constant + assert.Contains(t, result, "guard-policies", "agentic-workflows TOML should have guard-policies") + }) +} + +// TestNonGitHubMCPServersNoGuardPoliciesWhenGitHubNotConfigured verifies that servers +// do not get guard policies when the GitHub tool has no guard policy configured +func TestNonGitHubMCPServersNoGuardPoliciesWhenGitHubNotConfigured(t *testing.T) { + workflowData := &WorkflowData{ + Tools: map[string]any{ + "github": map[string]any{ + "toolsets": []string{"default"}, + }, + "playwright": nil, + }, + } + + policies := deriveWriteSinkGuardPolicyFromWorkflow(workflowData) + assert.Nil(t, policies, "no guard policies when GitHub has no guard policy configured") + + // Verify playwright JSON rendering has no guard-policies + var output strings.Builder + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "json", + IsLast: true, + WriteSinkGuardPolicies: policies, + }) + renderer.RenderPlaywrightMCP(&output, nil) + assert.NotContains(t, output.String(), "guard-policies", "playwright should not have guard-policies when GitHub has no guard policy") +} + +// TestNonGitHubMCPServersGetGuardPoliciesWhenGitHubConfigured verifies the end-to-end flow: +// when GitHub has repos=all, all non-GitHub MCP servers get write-sink: {accept: ["*"]} +func TestNonGitHubMCPServersGetGuardPoliciesWhenGitHubConfigured(t *testing.T) { + workflowData := &WorkflowData{ + Tools: map[string]any{ + "github": map[string]any{ + "repos": "all", + "min-integrity": "none", + }, + "playwright": nil, + }, + } + + policies := deriveWriteSinkGuardPolicyFromWorkflow(workflowData) + require.NotNil(t, policies, "guard policies should be derived when GitHub has guard policy") + + expectedPolicies := map[string]any{ + "write-sink": map[string]any{ + "accept": []string{"*"}, + }, + } + assert.Equal(t, expectedPolicies, policies, "policies should match expected write-sink with accept=*") + + // Verify playwright JSON rendering has guard-policies + var output strings.Builder + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + Format: "json", + IsLast: true, + WriteSinkGuardPolicies: policies, + }) + renderer.RenderPlaywrightMCP(&output, nil) + result := output.String() + assert.Contains(t, result, "\"guard-policies\"", "playwright should have guard-policies when GitHub has guard policy") + assert.Contains(t, result, "\"write-sink\"", "playwright should have write-sink policy") + assert.Contains(t, result, "\"accept\"", "playwright should have accept field") + assert.Contains(t, result, "\"*\"", "playwright should accept all patterns") +}