diff --git a/.changeset/enclave-force-public-repos.md b/.changeset/enclave-force-public-repos.md new file mode 100644 index 00000000000..f5e3ea854a6 --- /dev/null +++ b/.changeset/enclave-force-public-repos.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Disable the MCP gateway's `forcePublicRepos` override when the GitHub MCP server is rendered solely for a static agent enclave, so the enclave's configured `allowed-repos` is no longer silently rewritten to `repos: "public"` in public repositories. Compiling a static GitHub enclave alongside primary `tools.github` now warns that the enclave cannot read its declared private repositories. diff --git a/docs/public/schemas/mcp-gateway-config.schema.json b/docs/public/schemas/mcp-gateway-config.schema.json index e44f92dee51..6d8843725fc 100644 --- a/docs/public/schemas/mcp-gateway-config.schema.json +++ b/docs/public/schemas/mcp-gateway-config.schema.json @@ -333,7 +333,7 @@ }, "forcePublicRepos": { "type": "boolean", - "description": "When true (default), forces the allow-only policy to repos=\"public\" at runtime if the gateway detects it is running in a public repository. Set to false by the compiler when private-to-public-flows: allow is declared in workflow frontmatter, or via MCP_GATEWAY_FORCE_PUBLIC_REPOS=false environment variable. See MCP Gateway Specification section 4.1.3.8.", + "description": "When true (default), forces the allow-only policy to repos=\"public\" at runtime if the gateway detects it is running in a public repository. Set to false by the compiler when private-to-public-flows: allow is declared in workflow frontmatter or when the GitHub MCP backend serves only a static agent enclave, or via MCP_GATEWAY_FORCE_PUBLIC_REPOS=false environment variable. See MCP Gateway Specification section 4.1.3.8.", "default": true }, "sinkVisibilityExemptServers": { diff --git a/docs/src/content/docs/experimental/enclaves.md b/docs/src/content/docs/experimental/enclaves.md index b663cd4806d..e6dea6f6a9c 100644 --- a/docs/src/content/docs/experimental/enclaves.md +++ b/docs/src/content/docs/experimental/enclaves.md @@ -61,6 +61,10 @@ enclaves: - GraphQL, search, writes, and every other GitHub tool remain denied. - The minimum supported versions are AWF `v0.28.9` (or `v0.28.14` when using `trusted`) and mcpg `v0.4.15`. +Set `tools.github: false` so the GitHub MCP server is rendered for the enclave identity only. When primary-agent GitHub access is disabled, the compiler emits `"forcePublicRepos": false` in the gateway config: the gateway's runtime public-repos override would otherwise rewrite the enclave's allow-only scope to `repos: "public"` in a public repository, silently discarding `allowed-repos` and leaving the enclave with nothing to read. Disclosure stays bounded by the enclave's sensitivity ledger, `max-output-bytes`, and `max-invocations`, and safe outputs keep their explicit `sink-visibility` enforcement. + +If the primary agent also enables GitHub MCP access (`tools.github` other than `gh-proxy`), the override must stay on to protect the primary read path, and the compiler warns that the enclave cannot read the declared private repositories in a public repository. + ## Dynamic agent repository policies Agent enclaves can admit one repository per invocation at runtime without listing every repository in frontmatter. Dynamic entries use the same `awf-enclave` MCP backend, but replace static `repos` with a closed compiler-owned policy envelope: diff --git a/docs/src/content/docs/reference/mcp-gateway.md b/docs/src/content/docs/reference/mcp-gateway.md index 6aa014a53a0..e13dc12b1f9 100644 --- a/docs/src/content/docs/reference/mcp-gateway.md +++ b/docs/src/content/docs/reference/mcp-gateway.md @@ -255,7 +255,7 @@ The `gateway` section is required and configures gateway-specific behavior: | `keepaliveInterval` | integer | No | Keepalive ping interval in seconds for HTTP MCP backends. Prevents session expiry during long-running tasks. Use `-1` to disable, `0` or unset for gateway default (1500s = 25 min), or a positive integer for a custom interval. | | `sessionTimeout` | string | No | Session timeout for MCP gateway sessions as a Go duration string (e.g. `"30m"`, `"4h"`, `"24h"`). Empty or omitted uses the gateway default (6h). Must be at least 5m when set by the workflow compiler (no upper bound; infrastructure operators may override via `MCP_GATEWAY_SESSION_TIMEOUT` env var). | | `opentelemetry` | object | No | OpenTelemetry configuration for emitting distributed tracing events for MCP calls. See Section 4.1.3.7 for details. | -| `forcePublicRepos` | boolean | No | When `true` (default), forces the allow-only policy to `repos="public"` at runtime if the gateway detects it is running in a public repository. When `false`, disables this override — set by the compiler when `private-to-public-flows: allow` is declared in workflow frontmatter. See Section 4.1.3.8 for details. | +| `forcePublicRepos` | boolean | No | When `true` (default), forces the allow-only policy to `repos="public"` at runtime if the gateway detects it is running in a public repository. When `false`, disables this override — set by the compiler for `private-to-public-flows: allow` or an enclave-only static GitHub agent backend. See Section 4.1.3.8 for details. | | `sinkVisibilityExemptServers` | array[string] | No | List of server IDs exempt from the default `sink-visibility="public"` enforcement. Use `["*"]` to exempt all servers. Set by the compiler when `private-to-public-flows` lists specific server IDs in workflow frontmatter. See Section 10.9 for details. | *Exactly one of `agentId` or `agentIds` MUST be specified; the two fields are mutually exclusive. See Section 4.1.3.9. @@ -578,7 +578,7 @@ When `forcePublicRepos` is `true` (the default), the gateway overrides the compi - This override is **skipped** when `GITHUB_REPOSITORY` or the GitHub token is unavailable. - API errors during visibility detection result in a non-fatal warning; the gateway/proxy falls back to the compiled policy. -**Opt-out**: Workflow authors who intentionally allow private→public data flows set `private-to-public-flows: allow` in frontmatter (Section 10.9). The compiler translates this to `gateway.forcePublicRepos: false` in the generated gateway JSON stdin config. For proxy mode, launchers should pass `--force-public-repos=false` when they need equivalent opt-out behavior. +**Opt-out**: Workflow authors who intentionally allow private→public data flows set `private-to-public-flows: allow` in frontmatter (Section 10.9). The compiler translates this to `gateway.forcePublicRepos: false` in the generated gateway JSON stdin config. The compiler also emits `gateway.forcePublicRepos: false` when the GitHub MCP server is rendered solely to serve a static agent enclave identity (`tools.github: false` plus `enclaves[].agent.tools.github`), because the override would otherwise discard the enclave's configured `allowed-repos`. For proxy mode, launchers should pass `--force-public-repos=false` when they need equivalent opt-out behavior. **Environment variable override**: `MCP_GATEWAY_FORCE_PUBLIC_REPOS=false` disables the override without requiring a config change. The `--force-public-repos` flag defaults to the value of this environment variable (defaulting to `true` when unset). @@ -2331,7 +2331,7 @@ Content-Type: application/json - **Added**: Section 4.1.3.8 — `forcePublicRepos` Configuration - New optional boolean gateway config field (default: `true`) that forces the allow-only policy to `repos="public"` at runtime when the gateway detects it is running in a public repository - Prevents agents from accumulating private-data secrecy tags by restricting repository access at the input side - - Set to `false` by the compiler when `private-to-public-flows: allow` is declared in workflow frontmatter + - Set to `false` by the compiler when `private-to-public-flows: allow` is declared in workflow frontmatter or when the GitHub MCP backend serves only a static agent enclave - Can also be overridden via `MCP_GATEWAY_FORCE_PUBLIC_REPOS=false` environment variable - **Added**: `forcePublicRepos` field to the gateway configuration fields table (Section 4.1.3) - **Added**: Section 10.8 — Write-Sink Guard Policy (`sink-visibility`) diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index 02ebc110568..892d2210b79 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -269,9 +269,17 @@ func (c *Compiler) validateCoreToolConfiguration(workflowData *WorkflowData, mar return formatCompilerError(markdownPath, "error", err.Error(), err) } } + c.validateStaticEnclaveGitHubScopeOverrideWarning(workflowData) return nil } +func (c *Compiler) validateStaticEnclaveGitHubScopeOverrideWarning(workflowData *WorkflowData) { + if warning := staticEnclaveGitHubScopeOverrideWarning(workflowData); warning != "" { + fmt.Fprintln(os.Stderr, console.FormatWarningMessageStderr(warning)) + c.IncrementWarningCount() + } +} + func validateGitHubCLIProxyVersion(workflowData *WorkflowData) error { if !isGitHubCLIModeEnabled(workflowData) { return nil diff --git a/pkg/workflow/enclave_github_proxy_test.go b/pkg/workflow/enclave_github_proxy_test.go index b8156a82e35..ec738befe83 100644 --- a/pkg/workflow/enclave_github_proxy_test.go +++ b/pkg/workflow/enclave_github_proxy_test.go @@ -375,6 +375,46 @@ Read the private repository's issues through the enclave. assert.Contains(t, lock, `"${AWF_ENCLAVE_GITHUB_MCP_AGENT_ID}":{"servers":["github"],"tools":{"github":["list_issues","issue_read"]},"allow-only":{"min-integrity":"none","repos":["octo-org/private-service"]}}`) assert.Contains(t, lock, `export GH_AW_MCP_GITHUB_CHECK_AGENT_ID="${AWF_ENCLAVE_GITHUB_MCP_AGENT_ID}"`) assert.NotContains(t, lock, `"${MCP_GATEWAY_AGENT_ID}":{"servers":["awf-enclave","github"`) + + // The gateway's runtime forcePublicRepos override would rewrite the enclave's + // allow-only scope to repos="public" in a public repository, discarding + // allowed-repos and leaving the enclave with nothing to read. + assert.Contains(t, lock, `"forcePublicRepos": false`) +} + +// TestBuildMCPGatewayConfigForcePublicReposForStaticEnclave verifies that the gateway's +// runtime public-repos override is disabled when the GitHub MCP server exists solely to +// serve a static enclave agent identity, and left at its default when the primary agent +// also has GitHub MCP access. +func TestBuildMCPGatewayConfigForcePublicReposForStaticEnclave(t *testing.T) { + t.Run("enclave-only GitHub backend disables the override", func(t *testing.T) { + data := enclaveGitHubToolsWorkflowData() + delete(data.Tools, "github") + data.ExplicitlyDisabledTools = map[string]struct{}{"github": {}} + + cfg := buildMCPGatewayConfig(data) + require.NotNil(t, cfg) + require.NotNil(t, cfg.ForcePublicRepos, "ForcePublicRepos must be set for an enclave-only GitHub backend") + assert.False(t, *cfg.ForcePublicRepos) + }) + + t.Run("primary GitHub access keeps the override enabled", func(t *testing.T) { + data := enclaveGitHubToolsWorkflowData() + data.Tools["github"] = map[string]any{} + + cfg := buildMCPGatewayConfig(data) + require.NotNil(t, cfg) + assert.Nil(t, cfg.ForcePublicRepos, "ForcePublicRepos must stay at the gateway default when the primary agent reads GitHub") + }) + + t.Run("workflows without enclaves keep the override enabled", func(t *testing.T) { + data := enclaveWorkflowData(true, false, 120, 0) + data.Tools["github"] = map[string]any{} + + cfg := buildMCPGatewayConfig(data) + require.NotNil(t, cfg) + assert.Nil(t, cfg.ForcePublicRepos) + }) } func TestGitHubGuardPoliciesFromStepSkipsEnclaveOnlyBackend(t *testing.T) { @@ -436,3 +476,28 @@ func TestEnclaveGitHubToolsVersionGates(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), string(constants.MCPGEnclaveAgentToolsMinVersion)) } + +// TestStaticEnclaveGitHubScopeOverrideWarning verifies that combining a static GitHub agent +// enclave with primary-agent GitHub access is reported at compile time, because the gateway's +// forcePublicRepos override cannot be disabled without also relaxing the primary read path. +func TestStaticEnclaveGitHubScopeOverrideWarning(t *testing.T) { + data := enclaveGitHubToolsWorkflowData() + delete(data.Tools, "github") + data.ExplicitlyDisabledTools = map[string]struct{}{"github": {}} + assert.Empty(t, staticEnclaveGitHubScopeOverrideWarning(data)) + + data.Tools["github"] = map[string]any{} + assert.Contains(t, staticEnclaveGitHubScopeOverrideWarning(data), "static GitHub agent enclave is combined with primary 'tools.github'") + + assert.Empty(t, staticEnclaveGitHubScopeOverrideWarning(enclaveWorkflowData(true, false, 120, 0))) +} + +func TestStaticEnclaveGitHubScopeOverrideWarningIncrementsWarningCount(t *testing.T) { + compiler := NewCompiler() + data := enclaveGitHubToolsWorkflowData() + data.Tools["github"] = map[string]any{} + + require.NoError(t, compiler.validateCoreToolConfiguration(data, "")) + + assert.Equal(t, 1, compiler.GetWarningCount()) +} diff --git a/pkg/workflow/enclaves.go b/pkg/workflow/enclaves.go index bc78a26ed12..6818986c692 100644 --- a/pkg/workflow/enclaves.go +++ b/pkg/workflow/enclaves.go @@ -349,6 +349,24 @@ func validateEnclavesConfig(workflowData *WorkflowData) error { return nil } +// staticEnclaveGitHubScopeOverrideWarning returns a warning when a static GitHub agent +// enclave shares the GitHub MCP server with a GitHub-enabled primary agent, and an empty +// string otherwise. The gateway's forcePublicRepos safety net can only be disabled for the +// whole gateway, so it must stay enabled to protect the primary agent's read path. In a +// public repository that override rewrites the enclave's allow-only scope to repos="public", +// which silently discards enclaves[].agent.tools.github.allowed-repos and leaves the enclave +// unable to read the declared private repositories. +func staticEnclaveGitHubScopeOverrideWarning(workflowData *WorkflowData) string { + if !enclaveGitHubIssuesEnabled(workflowData) || !primaryGitHubMCPEnabled(workflowData) { + return "" + } + return "enclaves: a static GitHub agent enclave is combined with primary 'tools.github'. In a public repository " + + "the MCP gateway forces the GitHub allow-only scope to repos=\"public\", discarding the enclave scope from " + + "enclaves[].agent.tools.github.allowed-repos (or enclaves[].repos when it is omitted), so the enclave reads " + + "nothing. Remove primary 'tools.github' (set 'github: false') so the GitHub MCP server serves the enclave " + + "identity only." +} + func validateEnclaveEntry(index int, enclave *EnclaveConfig, seenTypes map[string]struct{}, repositorySensitivities map[string]string) error { if enclave == nil { return fmt.Errorf("enclaves[%d] must be an object. Example:\n\nenclaves:\n - script:\n repos:\n - repo: org/my-repo\n sensitivity: confidential", index) diff --git a/pkg/workflow/mcp_gateway_config.go b/pkg/workflow/mcp_gateway_config.go index 6d95582019f..d946b53cc23 100644 --- a/pkg/workflow/mcp_gateway_config.go +++ b/pkg/workflow/mcp_gateway_config.go @@ -218,6 +218,21 @@ func buildMCPGatewayConfig(workflowData *WorkflowData) *MCPGatewayRuntimeConfig } } + // A static GitHub agent enclave is the mechanism that makes private-to-public + // disclosure safe: reads happen inside an isolated executor and are bounded by the + // sensitivity ledger, max-output-bytes and max-invocations. When the GitHub MCP + // server is rendered solely to serve that enclave identity, the gateway's runtime + // forcePublicRepos override would rewrite the enclave's allow-only scope to + // repos="public" in a public repository, silently discarding the configured + // allowed-repos and leaving the enclave with nothing to read. Disable the override + // for that case: the primary agent has no GitHub read path at all here, and every + // write-sink guard policy emitted for this configuration carries an explicit + // sink-visibility, so safe-outputs enforcement is unaffected. + if forcePublicRepos == nil && githubBackendIsStaticEnclaveDelegationOnly(workflowData) { + falseVal := false + forcePublicRepos = &falseVal + } + config := &MCPGatewayRuntimeConfig{ Port: int(DefaultMCPGatewayPort), // Will be formatted as "${MCP_GATEWAY_PORT}" in renderer Domain: "${MCP_GATEWAY_DOMAIN}", // Gateway variable expression diff --git a/pkg/workflow/schemas/mcp-gateway-config.schema.json b/pkg/workflow/schemas/mcp-gateway-config.schema.json index ed3b8ca5327..462feeceaab 100644 --- a/pkg/workflow/schemas/mcp-gateway-config.schema.json +++ b/pkg/workflow/schemas/mcp-gateway-config.schema.json @@ -349,7 +349,7 @@ }, "forcePublicRepos": { "type": "boolean", - "description": "When true (default), forces the allow-only policy to repos=\"public\" at runtime if the gateway detects it is running in a public repository. Set to false by the compiler when private-to-public-flows: allow is declared in workflow frontmatter, or via MCP_GATEWAY_FORCE_PUBLIC_REPOS=false environment variable. See MCP Gateway Specification section 4.1.3.8.", + "description": "When true (default), forces the allow-only policy to repos=\"public\" at runtime if the gateway detects it is running in a public repository. Set to false by the compiler when private-to-public-flows: allow is declared in workflow frontmatter or when the GitHub MCP backend serves only a static agent enclave, or via MCP_GATEWAY_FORCE_PUBLIC_REPOS=false environment variable. See MCP Gateway Specification section 4.1.3.8.", "default": true }, "sinkVisibilityExemptServers": {