diff --git a/.github/workflows/daily-function-namer.lock.yml b/.github/workflows/daily-function-namer.lock.yml index 25810e63ba..301cefdb2f 100644 --- a/.github/workflows/daily-function-namer.lock.yml +++ b/.github/workflows/daily-function-namer.lock.yml @@ -869,7 +869,7 @@ jobs: - name: Append agent step summary if: always() run: bash /opt/gh-aw/actions/append_agent_step_summary.sh - - name: Copy safe outputs + - name: Copy Safe Outputs if: always() run: | mkdir -p /tmp/gh-aw diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 1ad3551828..238460c77b 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -29,7 +29,7 @@ # - shared/github-queries-mcp-script.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"b7ace3a0bae816ff9001b09294262e8a7367f7232f18b3b9918abf9d9d27253d","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"3542ab7695605246be9d78e721d6bedbcc1b3b0c9531254643458ddf40f90c29","strict":true} name: "Smoke Copilot" "on": @@ -1813,6 +1813,12 @@ jobs: "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + }, + "guard-policies": { + "allow-only": { + "min-integrity": "approved", + "repos": "public" + } } }, "mcpscripts": { diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 9e54e64339..b3142ddd8e 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -36,6 +36,8 @@ tools: bash: - "*" github: + repos: public + min-integrity: approved playwright: serena: languages: diff --git a/docs/public/schemas/mcp-gateway-config.schema.json b/docs/public/schemas/mcp-gateway-config.schema.json index e3606d9f9d..6854513e32 100644 --- a/docs/public/schemas/mcp-gateway-config.schema.json +++ b/docs/public/schemas/mcp-gateway-config.schema.json @@ -116,6 +116,11 @@ "type": "string" }, "default": ["*"] + }, + "guard-policies": { + "type": "object", + "description": "Guard policies for access control at the MCP gateway level. The structure of guard policies is server-specific. For GitHub MCP server, see the GitHub guard policy schema. For other servers (Jira, WorkIQ), different policy schemas will apply.", + "additionalProperties": true } }, "required": ["container"], diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 06d05b996c..4e8075d366 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -257,12 +257,16 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { } // deriveSafeOutputsGuardPolicyFromGitHub generates a safeoutputs guard-policy from GitHub guard-policy. -// When the GitHub MCP server has a guard-policy with repos, the safeoutputs MCP must also have -// a linked guard-policy. Each entry in the GitHub MCP server's "repos" must have a corresponding -// entry in safeoutputs "accept" with the prefix "private:". +// When the GitHub MCP server has a guard-policy with an array of specific repo patterns, the +// safeoutputs MCP must also have a linked guard-policy. Each entry in the "repos" array gets a +// corresponding "private:" accept entry in the safeoutputs write-sink policy. // -// This allows the gateway to read private data from the GitHub MCP server and still write to safeoutputs. -// Returns nil if no GitHub guard policies are configured. +// The safeoutputs server requires owner-scoped patterns (e.g., "private:owner/repo", +// "private:owner/*"). Bare wildcards like "private:*" are not valid because they lack an owner +// scope. For this reason, the global string keywords "all" and "public" do not produce a +// write-sink guard-policy — only array patterns with explicit owner/repo components do. +// +// Returns nil if no repos array guard policies are configured. func deriveSafeOutputsGuardPolicyFromGitHub(githubTool any) map[string]any { githubPolicies := getGitHubGuardPolicies(githubTool) if githubPolicies == nil { @@ -281,21 +285,24 @@ func deriveSafeOutputsGuardPolicyFromGitHub(githubTool any) map[string]any { return nil } - // Convert repos to accept list with "private:" prefix + // Convert repos to accept list with "private:" prefix. + // Only array patterns produce a write-sink policy; string keywords ("all", "public") do not, + // because the safeoutputs server requires owner-scoped patterns (e.g., "private:owner/repo"). var acceptList []string switch r := repos.(type) { case string: - // Single string value (e.g., "all", "public", or a pattern) + // Global string keywords cannot be expressed as owner-scoped patterns. + // The safeoutputs server requires "prefix:owner/repo" format; bare wildcards + // like "private:*" (no owner) are not valid scopes. if r == "all" || r == "public" { - // For "all" or "public", add "private:*" to accept all private repos - acceptList = []string{"private:*"} - } else { - // Single pattern - add with private: prefix - acceptList = []string{"private:" + r} + githubConfigLog.Printf("repos=%q is a global keyword — no owner-scoped pattern possible, no safeoutputs write-sink guard-policy derived", r) + return nil } + // Single owner-scoped pattern (e.g., "owner/repo"): use private: prefix + acceptList = []string{"private:" + r} case []any: - // Array of patterns + // Array of owner-scoped patterns: generate private: accept entries acceptList = make([]string, 0, len(r)) for _, item := range r { if pattern, ok := item.(string); ok { @@ -303,7 +310,7 @@ func deriveSafeOutputsGuardPolicyFromGitHub(githubTool any) map[string]any { } } case []string: - // Array of patterns (already strings) + // Array of owner-scoped patterns (already strings): generate private: accept entries acceptList = make([]string, 0, len(r)) for _, pattern := range r { acceptList = append(acceptList, "private:"+pattern) @@ -314,6 +321,10 @@ func deriveSafeOutputsGuardPolicyFromGitHub(githubTool any) map[string]any { return nil } + if len(acceptList) == 0 { + return nil + } + // Build the write-sink policy for safeoutputs return map[string]any{ "write-sink": map[string]any{ diff --git a/pkg/workflow/safeoutputs_guard_policy_test.go b/pkg/workflow/safeoutputs_guard_policy_test.go index 8ccf781264..1ebb100bb2 100644 --- a/pkg/workflow/safeoutputs_guard_policy_test.go +++ b/pkg/workflow/safeoutputs_guard_policy_test.go @@ -52,13 +52,8 @@ func TestDeriveSafeOutputsGuardPolicyFromGitHub(t *testing.T) { "repos": "all", "min-integrity": "approved", }, - expectedPolicies: map[string]any{ - "write-sink": map[string]any{ - "accept": []string{"private:*"}, - }, - }, - expectNil: false, - description: "repos='all' should map to private:*", + expectNil: true, + description: "repos='all' is a global keyword — no owner-scoped pattern possible, no write-sink derived", }, { name: "repos set to public", @@ -66,13 +61,8 @@ func TestDeriveSafeOutputsGuardPolicyFromGitHub(t *testing.T) { "repos": "public", "min-integrity": "none", }, - expectedPolicies: map[string]any{ - "write-sink": map[string]any{ - "accept": []string{"private:*"}, - }, - }, - expectNil: false, - description: "repos='public' should map to private:*", + expectNil: true, + description: "repos='public' is a global keyword — no owner-scoped pattern possible, no write-sink derived", }, { name: "multiple repo patterns as []any",