Skip to content

Commit 7f805c0

Browse files
Copilotlpcox
andcommitted
feat: add write-sink guard policy to all non-GitHub MCP servers configured by gateway
Extend the write-sink guard policy (currently applied only to safe-outputs) to ALL non-GitHub MCP servers exposed by the MCP gateway. The policy is derived from the same GitHub guard-policy parameters (repos/min-integrity), ensuring that as guard policies are rolled out, only GitHub inputs will be filtered while outputs to non-GitHub servers are not restricted. Servers updated: playwright, serena, mcp-scripts, agentic-workflows, web-fetch, and all custom user-defined MCP tools. Both JSON format (for gateway/Claude/Copilot/Gemini) and TOML format (for Codex) are updated with guard policy rendering. Also adds a deriveWriteSinkGuardPolicyFromWorkflow helper and a comprehensive test file covering the new behavior. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
1 parent 4472901 commit 7f805c0

18 files changed

Lines changed: 582 additions & 50 deletions

pkg/workflow/claude_mcp.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ func (e *ClaudeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a
1616
// Claude uses JSON format without Copilot-specific fields and multi-line args
1717
createRenderer := func(isLast bool) *MCPConfigRendererUnified {
1818
return NewMCPConfigRenderer(MCPRendererOptions{
19-
IncludeCopilotFields: false, // Claude doesn't use "type" and "tools" fields
20-
InlineArgs: false, // Claude uses multi-line args format
21-
Format: "json",
22-
IsLast: isLast,
23-
ActionMode: GetActionModeFromWorkflowData(workflowData),
19+
IncludeCopilotFields: false, // Claude doesn't use "type" and "tools" fields
20+
InlineArgs: false, // Claude uses multi-line args format
21+
Format: "json",
22+
IsLast: isLast,
23+
ActionMode: GetActionModeFromWorkflowData(workflowData),
24+
WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
2425
})
2526
}
2627

@@ -59,7 +60,7 @@ func (e *ClaudeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a
5960
renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData)
6061
},
6162
RenderWebFetch: func(yaml *strings.Builder, isLast bool) {
62-
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false)
63+
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData))
6364
},
6465
RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
6566
return e.renderClaudeMCPConfigWithContext(yaml, toolName, toolConfig, isLast, workflowData)

pkg/workflow/codex_mcp.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an
1919
// Codex uses TOML format without Copilot-specific fields and multi-line args
2020
createRenderer := func(isLast bool) *MCPConfigRendererUnified {
2121
return NewMCPConfigRenderer(MCPRendererOptions{
22-
IncludeCopilotFields: false, // Codex doesn't use "type" and "tools" fields
23-
InlineArgs: false, // Codex uses multi-line args format
24-
Format: "toml",
25-
IsLast: isLast,
26-
ActionMode: GetActionModeFromWorkflowData(workflowData),
22+
IncludeCopilotFields: false, // Codex doesn't use "type" and "tools" fields
23+
InlineArgs: false, // Codex uses multi-line args format
24+
Format: "toml",
25+
IsLast: isLast,
26+
ActionMode: GetActionModeFromWorkflowData(workflowData),
27+
WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
2728
})
2829
}
2930

@@ -69,7 +70,7 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an
6970
renderer.RenderMCPScriptsMCP(yaml, workflowData.MCPScripts, workflowData)
7071
}
7172
case "web-fetch":
72-
renderMCPFetchServerConfig(yaml, "toml", " ", false, false)
73+
renderMCPFetchServerConfig(yaml, "toml", " ", false, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData))
7374
default:
7475
// Handle custom MCP tools using shared helper (with adapter for isLast parameter)
7576
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
112113
actionMode = workflowData.ActionMode
113114
}
114115
return NewMCPConfigRenderer(MCPRendererOptions{
115-
IncludeCopilotFields: false, // Gateway doesn't need Copilot fields
116-
InlineArgs: false, // Use standard multi-line format
117-
Format: "json",
118-
IsLast: isLast,
119-
ActionMode: actionMode,
116+
IncludeCopilotFields: false, // Gateway doesn't need Copilot fields
117+
InlineArgs: false, // Use standard multi-line format
118+
Format: "json",
119+
IsLast: isLast,
120+
ActionMode: actionMode,
121+
WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
120122
})
121123
}
122124

@@ -152,7 +154,7 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an
152154
renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData)
153155
},
154156
RenderWebFetch: func(yaml *strings.Builder, isLast bool) {
155-
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false)
157+
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData))
156158
},
157159
RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
158160
return e.renderCodexJSONMCPConfigWithContext(yaml, toolName, toolConfig, isLast, workflowData)
@@ -177,6 +179,7 @@ func (e *CodexEngine) renderCodexMCPConfigWithContext(yaml *strings.Builder, too
177179
IndentLevel: " ",
178180
Format: "toml",
179181
RewriteLocalhostToDocker: rewriteLocalhost,
182+
GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
180183
}
181184

182185
err := renderSharedMCPConfig(yaml, toolName, toolConfig, renderer)
@@ -200,6 +203,7 @@ func (e *CodexEngine) renderCodexJSONMCPConfigWithContext(yaml *strings.Builder,
200203
Format: "json",
201204
IndentLevel: " ",
202205
RewriteLocalhostToDocker: rewriteLocalhost,
206+
GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
203207
}
204208

205209
yaml.WriteString(" \"" + toolName + "\": {\n")

pkg/workflow/copilot_mcp.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ func (e *CopilotEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]
1919
// Copilot uses JSON format with type and tools fields, and inline args
2020
createRenderer := func(isLast bool) *MCPConfigRendererUnified {
2121
return NewMCPConfigRenderer(MCPRendererOptions{
22-
IncludeCopilotFields: true, // Copilot uses "type" and "tools" fields
23-
InlineArgs: true, // Copilot uses inline args format
24-
Format: "json",
25-
IsLast: isLast,
26-
ActionMode: GetActionModeFromWorkflowData(workflowData),
22+
IncludeCopilotFields: true, // Copilot uses "type" and "tools" fields
23+
InlineArgs: true, // Copilot uses inline args format
24+
Format: "json",
25+
IsLast: isLast,
26+
ActionMode: GetActionModeFromWorkflowData(workflowData),
27+
WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
2728
})
2829
}
2930

@@ -64,7 +65,7 @@ func (e *CopilotEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]
6465
renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData)
6566
},
6667
RenderWebFetch: func(yaml *strings.Builder, isLast bool) {
67-
renderMCPFetchServerConfig(yaml, "json", " ", isLast, true)
68+
renderMCPFetchServerConfig(yaml, "json", " ", isLast, true, deriveWriteSinkGuardPolicyFromWorkflow(workflowData))
6869
},
6970
RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
7071
return e.renderCopilotMCPConfigWithContext(yaml, toolName, toolConfig, isLast, workflowData)
@@ -96,6 +97,7 @@ func (e *CopilotEngine) renderCopilotMCPConfigWithContext(yaml *strings.Builder,
9697
IndentLevel: " ",
9798
RequiresCopilotFields: true,
9899
RewriteLocalhostToDocker: rewriteLocalhost,
100+
GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
99101
}
100102

101103
yaml.WriteString(" \"" + toolName + "\": {\n")

pkg/workflow/fetch.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,21 @@ func AddMCPFetchServerIfNeeded(tools map[string]any, engine CodingAgentEngine) (
5353
// renderMCPFetchServerConfig renders the MCP fetch server configuration
5454
// This is a shared function that can be used by all engines
5555
// includeTools parameter adds "tools": ["*"] field for engines that require it (e.g., Copilot)
56-
func renderMCPFetchServerConfig(yaml *strings.Builder, format string, indent string, isLast bool, includeTools bool) {
56+
// guardPolicies parameter adds write-sink guard policies when derived from the GitHub guard-policy
57+
func renderMCPFetchServerConfig(yaml *strings.Builder, format string, indent string, isLast bool, includeTools bool, guardPolicies map[string]any) {
5758
fetchLog.Printf("Rendering MCP fetch server config: format=%s, includeTools=%v", format, includeTools)
5859

5960
switch format {
6061
case "json":
6162
// JSON format (for Claude, Copilot, Custom engines)
6263
// Use container key per MCP Gateway schema (container-based stdio server)
6364
yaml.WriteString(indent + "\"web-fetch\": {\n")
64-
yaml.WriteString(indent + " \"container\": \"mcp/fetch\"\n")
65+
if len(guardPolicies) > 0 {
66+
yaml.WriteString(indent + " \"container\": \"mcp/fetch\",\n")
67+
renderGuardPoliciesJSON(yaml, guardPolicies, indent+" ")
68+
} else {
69+
yaml.WriteString(indent + " \"container\": \"mcp/fetch\"\n")
70+
}
6571
if isLast {
6672
yaml.WriteString(indent + "}\n")
6773
} else {
@@ -73,5 +79,9 @@ func renderMCPFetchServerConfig(yaml *strings.Builder, format string, indent str
7379
yaml.WriteString(indent + "\n")
7480
yaml.WriteString(indent + "[mcp_servers.\"web-fetch\"]\n")
7581
yaml.WriteString(indent + "container = \"mcp/fetch\"\n")
82+
// Add guard policies as a separate TOML section if configured
83+
if len(guardPolicies) > 0 {
84+
renderGuardPoliciesToml(yaml, guardPolicies, "web-fetch")
85+
}
7686
}
7787
}

pkg/workflow/fetch_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ func TestRenderMCPFetchServerConfig(t *testing.T) {
188188
for _, tt := range tests {
189189
t.Run(tt.name, func(t *testing.T) {
190190
var yaml strings.Builder
191-
renderMCPFetchServerConfig(&yaml, tt.format, tt.indent, tt.isLast, tt.includeTools)
191+
renderMCPFetchServerConfig(&yaml, tt.format, tt.indent, tt.isLast, tt.includeTools, nil)
192192
output := yaml.String()
193193

194194
for _, substr := range tt.expectSubstr {

pkg/workflow/gemini_mcp.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ func (e *GeminiEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a
1515
// Create unified renderer with Gemini-specific options
1616
createRenderer := func(isLast bool) *MCPConfigRendererUnified {
1717
return NewMCPConfigRenderer(MCPRendererOptions{
18-
IncludeCopilotFields: false,
19-
InlineArgs: false,
20-
Format: "json", // Gemini uses JSON format like Claude/Codex
21-
IsLast: isLast,
22-
ActionMode: GetActionModeFromWorkflowData(workflowData),
18+
IncludeCopilotFields: false,
19+
InlineArgs: false,
20+
Format: "json", // Gemini uses JSON format like Claude/Codex
21+
IsLast: isLast,
22+
ActionMode: GetActionModeFromWorkflowData(workflowData),
23+
WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
2324
})
2425
}
2526

@@ -54,7 +55,7 @@ func (e *GeminiEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a
5455
renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData)
5556
},
5657
RenderWebFetch: func(yaml *strings.Builder, isLast bool) {
57-
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false)
58+
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData))
5859
},
5960
RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
6061
return renderCustomMCPConfigWrapperWithContext(yaml, toolName, toolConfig, isLast, workflowData)

pkg/workflow/mcp_config_builtin.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ func renderSafeOutputsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, i
172172
// renderAgenticWorkflowsMCPConfigWithOptions generates the Agentic Workflows MCP server configuration with engine-specific options
173173
// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized.
174174
// Uses MCP Gateway spec format: container, entrypoint, entrypointArgs, and mounts fields.
175-
func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, actionMode ActionMode) {
175+
func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, actionMode ActionMode, guardPolicies map[string]any) {
176176
mcpBuiltinLog.Printf("Rendering Agentic Workflows MCP config: isLast=%v, includeCopilotFields=%v, actionMode=%v", isLast, includeCopilotFields, actionMode)
177177

178178
// 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
288288

289289
yaml.WriteString(" \"" + envVar.name + "\": \"" + valueStr + "\"" + comma + "\n")
290290
}
291-
yaml.WriteString(" }\n")
291+
// Close env section - with or without trailing comma depending on whether guard policies follow
292+
if len(guardPolicies) > 0 {
293+
yaml.WriteString(" },\n")
294+
renderGuardPoliciesJSON(yaml, guardPolicies, " ")
295+
} else {
296+
yaml.WriteString(" }\n")
297+
}
292298

293299
if isLast {
294300
yaml.WriteString(" }\n")

pkg/workflow/mcp_config_comprehensive_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,7 @@ func TestRenderSerenaMCPConfigWithOptions(t *testing.T) {
653653
t.Run(tt.name, func(t *testing.T) {
654654
var output strings.Builder
655655

656-
renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs)
656+
renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs, nil)
657657

658658
result := output.String()
659659

@@ -1153,7 +1153,7 @@ func TestRenderSerenaMCPConfigDockerMode(t *testing.T) {
11531153
t.Run(tt.name, func(t *testing.T) {
11541154
var output strings.Builder
11551155

1156-
renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs)
1156+
renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs, nil)
11571157

11581158
result := output.String()
11591159

pkg/workflow/mcp_config_custom.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func renderCustomMCPConfigWrapperWithContext(yaml *strings.Builder, toolName str
3131
IndentLevel: " ",
3232
Format: "json",
3333
RewriteLocalhostToDocker: rewriteLocalhost,
34+
GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
3435
}
3536

3637
err := renderSharedMCPConfig(yaml, toolName, toolConfig, renderer)
@@ -181,9 +182,14 @@ func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig ma
181182
return nil
182183
}
183184

185+
// When guard policies are present in JSON format, they become the actual last field.
186+
// The last existing property must have a trailing comma to allow appending guard policies.
187+
hasTrailingGuardPolicies := renderer.Format == "json" && len(renderer.GuardPolicies) > 0
188+
184189
// Render properties based on format
185190
for propIndex, property := range existingProperties {
186-
isLast := propIndex == len(existingProperties)-1
191+
// In JSON format, if guard policies follow, the last existing property is no longer "last"
192+
isLast := (propIndex == len(existingProperties)-1) && !hasTrailingGuardPolicies
187193

188194
switch property {
189195
case "type":
@@ -497,6 +503,15 @@ func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig ma
497503
}
498504
}
499505

506+
// Render guard policies after all properties
507+
if hasTrailingGuardPolicies {
508+
// JSON format: guard policies are the last field inside the server object
509+
renderGuardPoliciesJSON(yaml, renderer.GuardPolicies, renderer.IndentLevel)
510+
} else if renderer.Format == "toml" && len(renderer.GuardPolicies) > 0 {
511+
// TOML format: guard policies are a separate TOML section after the server config
512+
renderGuardPoliciesToml(yaml, renderer.GuardPolicies, toolName)
513+
}
514+
500515
return nil
501516
}
502517

pkg/workflow/mcp_config_playwright_renderer.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ var mcpPlaywrightLog = logger.New("workflow:mcp_config_playwright_renderer")
6868
// renderPlaywrightMCPConfigWithOptions generates the Playwright MCP server configuration with engine-specific options
6969
// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized.
7070
// Uses MCP Gateway spec format: container, entrypointArgs, mounts, and args fields.
71-
func renderPlaywrightMCPConfigWithOptions(yaml *strings.Builder, playwrightConfig *PlaywrightToolConfig, isLast bool, includeCopilotFields bool, inlineArgs bool) {
71+
func renderPlaywrightMCPConfigWithOptions(yaml *strings.Builder, playwrightConfig *PlaywrightToolConfig, isLast bool, includeCopilotFields bool, inlineArgs bool, guardPolicies map[string]any) {
7272
mcpPlaywrightLog.Printf("Rendering Playwright MCP config options: copilot_fields=%t, inline_args=%t", includeCopilotFields, inlineArgs)
7373
customArgs := getPlaywrightCustomArgs(playwrightConfig)
7474

@@ -155,7 +155,13 @@ func renderPlaywrightMCPConfigWithOptions(yaml *strings.Builder, playwrightConfi
155155
}
156156

157157
// Add volume mounts
158-
yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"]\n")
158+
// When guard policies follow, mounts is not the last field (add trailing comma)
159+
if len(guardPolicies) > 0 {
160+
yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"],\n")
161+
renderGuardPoliciesJSON(yaml, guardPolicies, " ")
162+
} else {
163+
yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"]\n")
164+
}
159165

160166
// Note: tools field is NOT included here - the converter script adds it back
161167
// for Copilot. This keeps the gateway config compatible with the schema.

0 commit comments

Comments
 (0)