diff --git a/docs/adr/25948-version-gated-integrity-reactions-for-mcpg-allow-only-policy.md b/docs/adr/25948-version-gated-integrity-reactions-for-mcpg-allow-only-policy.md new file mode 100644 index 00000000000..de340abf590 --- /dev/null +++ b/docs/adr/25948-version-gated-integrity-reactions-for-mcpg-allow-only-policy.md @@ -0,0 +1,92 @@ +# ADR-25948: Version-Gated Integrity Reactions for MCPG Allow-Only Policy + +**Date**: 2026-04-13 +**Status**: Draft +**Deciders**: lpcox, Copilot (inferred from PR #25948) + +--- + +## Part 1 — Narrative (Human-Friendly) + +### Context + +The gh-aw workflow compiler generates MCP gateway (MCPG) guard policies that control which tool calls agents are allowed to make. Until now, integrity promotion and demotion was determined solely by static fields (`min-integrity`, `repos`) in the `allow-only` policy block. A new capability in MCPG v0.2.18 allows reaction-based integrity signals: GitHub reactions (e.g., 👍, ❤️) from maintainers can dynamically promote or demote the content integrity level, enabling lightweight, in-band approval workflows without requiring separate label-based gating. Introducing this capability requires extending the compiler in a way that is both backwards-compatible with existing workflows and gated to MCPG versions that support it. + +### Decision + +We will introduce a `integrity-reactions` feature flag that workflow authors must explicitly opt into, combined with a semver version gate that ensures the feature is only compiled into guard policies when the configured MCPG version is `>= v0.2.18`. A shared `injectIntegrityReactionFields()` helper centralizes the injection logic and is called from both the MCP renderer (`mcp_renderer_github.go`) and the DIFC proxy policy builder (`compiler_difc_proxy.go`), ensuring consistent behavior across all policy code paths. The default MCPG version (`v0.2.17`) is deliberately below the minimum, so no existing workflow is affected without an explicit opt-in. + +### Alternatives Considered + +#### Alternative 1: Unconditional Rollout (No Feature Flag) + +Add `endorsement-reactions` and `disapproval-reactions` to the allow-only policy for all workflows that already set `min-integrity`. This would require no feature flag infrastructure but would silently change the behaviour of every existing workflow using integrity gating as soon as MCPG >= v0.2.18 is deployed. Reaction fields default to empty arrays in MCPG so the net change would likely be benign, but the compiler would generate different output for unchanged workflow files, violating the principle that `make recompile` is idempotent without frontmatter changes. This alternative was rejected because it breaks the stable, reproducible lock-file guarantee. + +#### Alternative 2: Separate Policy Type for Reaction-Based Integrity + +Introduce a new top-level policy key (e.g., `reaction-integrity`) separate from the existing `allow-only` block, requiring workflow authors to restructure their guard policy when adding reactions. This would be a cleaner schema evolution in isolation but would break the conceptual unity of the guard policy (integrity level and reactions belong to the same policy object in MCPG) and would force unnecessary churn for adopters already using `min-integrity`. It was rejected because the MCPG data model treats reactions as additional fields within the existing `allow-only` block, so mirroring that structure in the frontmatter is more natural and less disruptive. + +#### Alternative 3: Compiler-Inlined Version Check Instead of Helper + +Duplicate the semver version-gate logic inline at each call site (MCP renderer and DIFC proxy builder) rather than centralizing it in `mcpgSupportsIntegrityReactions()` and `injectIntegrityReactionFields()`. This would eliminate the shared helper but scatter the version-comparison logic and the reaction-injection logic across multiple files, making it harder to update the minimum version or add new reaction fields in the future. It was rejected because the injection logic is non-trivial (four optional fields, two code paths) and centralization reduces the surface area for bugs when either code path is later changed. + +### Consequences + +#### Positive +- Existing workflows are completely unaffected — `make recompile` produces no diff unless the `integrity-reactions` feature flag is explicitly enabled in frontmatter. +- A single `injectIntegrityReactionFields()` helper ensures both the MCP renderer and DIFC proxy policy builder stay in sync when reaction fields are added or modified. +- Compile-time validation (`validateIntegrityReactions()`) catches invalid reaction content enum values and missing `min-integrity` prerequisites before any workflow runs. +- The semver gate pattern is consistent with the `version-gated-no-ask-user-flag` decision (ADR-25822), reinforcing a repository-wide convention for introducing MCPG-version-specific features. + +#### Negative +- Workflow authors who want reaction-based integrity must add both `features: integrity-reactions: true` and update their MCPG version to `>= v0.2.18` — a two-part opt-in that could cause confusion if only one is set (though validation errors guide the author). +- The `getDIFCProxyPolicyJSON` function signature changed from `(githubTool any)` to `(githubTool any, data *WorkflowData, gatewayConfig *MCPGatewayRuntimeConfig)`, making it a slightly more complex internal API. +- The `ensureDefaultMCPGatewayConfig(data)` call was moved earlier in `buildStartDIFCProxyStepYAML` to ensure the gateway config is populated before policy injection — a subtle ordering dependency that future maintainers must preserve. + +#### Neutral +- The `validReactionContents` enum set matches the GitHub GraphQL `ReactionContent` enum at the time of writing; if GitHub adds new reaction types, the validation set must be updated manually. +- The "latest" version string is treated as always supporting the feature — a pragmatic choice that simplifies CI pipelines that pin to `latest`, at the cost of slightly weaker version semantics. +- JSON schema (`main_workflow_schema.json`) was extended with enum constraints for the new fields, providing IDE autocompletion and static validation independent of the Go validation layer. + +--- + +## Part 2 — Normative Specification (RFC 2119) + +> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +### Feature Flag and Version Gate + +1. Implementations **MUST NOT** inject `endorsement-reactions`, `disapproval-reactions`, `disapproval-integrity`, or `endorser-min-integrity` into any MCPG guard policy unless the `integrity-reactions` feature flag is explicitly enabled in the workflow frontmatter. +2. Implementations **MUST NOT** inject reaction fields if the effective MCPG version is below `v0.2.18`, even when the feature flag is enabled. +3. Implementations **MUST** treat the string `"latest"` (case-insensitive) as satisfying the minimum MCPG version requirement. +4. Implementations **MUST** treat any non-semver MCPG version string (other than `"latest"`) as failing the version gate, defaulting to conservative rejection. +5. Implementations **MUST** use `DefaultMCPGatewayVersion` when no MCPG version is explicitly configured, which **MUST** be a version below `MCPGIntegrityReactionsMinVersion` to preserve backwards compatibility. + +### Reaction Field Injection + +1. Implementations **MUST** inject reaction fields via the shared `injectIntegrityReactionFields()` helper — direct inline injection at individual call sites is **NOT RECOMMENDED**. +2. `injectIntegrityReactionFields()` **MUST** be called in all policy-generation code paths, including the MCP renderer (`mcp_renderer_github.go`) and the DIFC proxy policy builder (`compiler_difc_proxy.go`). +3. Implementations **MUST** inject reaction fields into the inner `allow-only` policy map, not into the outer policy wrapper object. +4. Implementations **SHOULD** call `ensureDefaultMCPGatewayConfig(data)` before invoking `injectIntegrityReactionFields()` to guarantee the gateway config is non-nil. + +### Validation + +1. Implementations **MUST** validate that `endorsement-reactions` and `disapproval-reactions` contain only values from the GitHub `ReactionContent` enum: `THUMBS_UP`, `THUMBS_DOWN`, `HEART`, `HOORAY`, `CONFUSED`, `ROCKET`, `EYES`, `LAUGH`. +2. Implementations **MUST** return a compile-time error if any reaction array field is set without the `integrity-reactions` feature flag. +3. Implementations **MUST** return a compile-time error if the `integrity-reactions` feature flag is enabled but the MCPG version is below `v0.2.18`. +4. Implementations **MUST** return a compile-time error if `endorsement-reactions` or `disapproval-reactions` are set without `min-integrity` being configured. +5. Implementations **MUST** validate that `disapproval-integrity`, when set, is one of: `"none"`, `"unapproved"`, `"approved"`, `"merged"`. +6. Implementations **MUST** validate that `endorser-min-integrity`, when set, is one of: `"unapproved"`, `"approved"`, `"merged"`. + +### Schema + +1. The JSON schema for workflow frontmatter **MUST** define `endorsement-reactions` and `disapproval-reactions` as arrays of strings constrained to the `ReactionContent` enum values. +2. The JSON schema **MUST** define `disapproval-integrity` and `endorser-min-integrity` as strings constrained to their respective valid integrity level sets. + +### Conformance + +An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance. In particular: injecting reaction fields without the feature flag, injecting reaction fields when the MCPG version is below `v0.2.18`, or omitting validation of reaction enum values are all non-conformant behaviors. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/constants/feature_constants.go b/pkg/constants/feature_constants.go index 5f69e747ad6..b2952a05ade 100644 --- a/pkg/constants/feature_constants.go +++ b/pkg/constants/feature_constants.go @@ -49,4 +49,14 @@ const ( // features: // copilot-integration-id: true CopilotIntegrationIDFeatureFlag FeatureFlag = "copilot-integration-id" + // IntegrityReactionsFeatureFlag enables reaction-based integrity promotion/demotion + // in the MCPG allow-only policy. When enabled, the compiler injects + // endorsement-reactions and disapproval-reactions fields into the allow-only policy. + // Requires MCPG >= v0.2.18. + // + // Workflow frontmatter usage: + // + // features: + // integrity-reactions: true + IntegrityReactionsFeatureFlag FeatureFlag = "integrity-reactions" ) diff --git a/pkg/constants/version_constants.go b/pkg/constants/version_constants.go index 2d4aefe25d2..6204143fe09 100644 --- a/pkg/constants/version_constants.go +++ b/pkg/constants/version_constants.go @@ -70,6 +70,10 @@ const CopilotNoAskUserMinVersion Version = "1.0.19" // DefaultMCPGatewayVersion is the default version of the MCP Gateway (gh-aw-mcpg) Docker image const DefaultMCPGatewayVersion Version = "v0.2.17" +// MCPGIntegrityReactionsMinVersion is the minimum MCPG version that supports +// endorsement-reactions and disapproval-reactions in the allow-only policy. +const MCPGIntegrityReactionsMinVersion Version = "v0.2.18" + // DefaultPlaywrightMCPVersion is the default version of the @playwright/mcp package const DefaultPlaywrightMCPVersion Version = "0.0.70" diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 92ae5a4c222..ec59a0ce135 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3674,6 +3674,40 @@ } ] }, + "endorsement-reactions": { + "type": "array", + "description": "Guard policy: GitHub reaction types that promote a content item's integrity to 'approved' when added by maintainers. Only enforced in proxy mode (DIFC/CLI proxy); ignored in MCP gateway mode because reaction authors cannot be identified. Optional; defaults to [\"THUMBS_UP\", \"HEART\"] when the integrity-reactions feature flag is enabled. Requires 'min-integrity' to be set and MCPG >= v0.2.18.", + "items": { + "type": "string", + "description": "GitHub ReactionContent enum value", + "enum": ["THUMBS_UP", "THUMBS_DOWN", "HEART", "HOORAY", "CONFUSED", "ROCKET", "EYES", "LAUGH"] + }, + "default": ["THUMBS_UP", "HEART"], + "examples": [["THUMBS_UP", "HEART"]] + }, + "disapproval-reactions": { + "type": "array", + "description": "Guard policy: GitHub reaction types that demote content integrity when added by maintainers. Only enforced in proxy mode (DIFC/CLI proxy); ignored in MCP gateway mode because reaction authors cannot be identified. Optional; defaults to [\"THUMBS_DOWN\", \"CONFUSED\"] when the integrity-reactions feature flag is enabled. Disapproval overrides endorsement (safe default). Requires 'min-integrity' to be set and MCPG >= v0.2.18.", + "items": { + "type": "string", + "description": "GitHub ReactionContent enum value", + "enum": ["THUMBS_UP", "THUMBS_DOWN", "HEART", "HOORAY", "CONFUSED", "ROCKET", "EYES", "LAUGH"] + }, + "default": ["THUMBS_DOWN", "CONFUSED"], + "examples": [["THUMBS_DOWN", "CONFUSED"]] + }, + "disapproval-integrity": { + "type": "string", + "description": "Guard policy: integrity level assigned when a disapproval reaction is present. Optional, defaults to 'none'. Requires the 'integrity-reactions' feature flag and MCPG >= v0.2.18.", + "enum": ["none", "unapproved", "approved", "merged"], + "default": "none" + }, + "endorser-min-integrity": { + "type": "string", + "description": "Guard policy: minimum integrity level required for an endorser (reactor) to promote content. Optional, defaults to 'approved'. Requires the 'integrity-reactions' feature flag and MCPG >= v0.2.18.", + "enum": ["unapproved", "approved", "merged"], + "default": "approved" + }, "github-app": { "$ref": "#/$defs/github_app", "description": "GitHub App configuration for token minting. When configured, a GitHub App installation access token is minted at workflow start and used instead of the default token. This token overrides any custom github-token setting and provides fine-grained permissions matching the agent job requirements." diff --git a/pkg/workflow/compiler_difc_proxy.go b/pkg/workflow/compiler_difc_proxy.go index 04e99dbe00e..a8707d29cae 100644 --- a/pkg/workflow/compiler_difc_proxy.go +++ b/pkg/workflow/compiler_difc_proxy.go @@ -157,8 +157,12 @@ func hasPreAgentStepsWithGHToken(data *WorkflowData) bool { // compile time: min-integrity and repos. This is because the proxy starts before the // parse-guard-vars step that produces those dynamic outputs. // +// When the integrity-reactions feature flag is enabled and the MCPG version supports it, +// reaction fields (endorsement-reactions, disapproval-reactions, disapproval-integrity, +// endorser-min-integrity) are also included in the proxy policy. +// // Returns an empty string if no guard policy fields are found. -func getDIFCProxyPolicyJSON(githubTool any) string { +func getDIFCProxyPolicyJSON(githubTool any, data *WorkflowData, gatewayConfig *MCPGatewayRuntimeConfig) string { toolConfig, ok := githubTool.(map[string]any) if !ok { return "" @@ -188,6 +192,9 @@ func getDIFCProxyPolicyJSON(githubTool any) string { policy["min-integrity"] = integrity } + // Inject reaction fields when the feature flag is enabled and MCPG supports it. + injectIntegrityReactionFields(policy, toolConfig, data, gatewayConfig) + guardPolicy := map[string]any{ "allow-only": policy, } @@ -224,7 +231,9 @@ func (c *Compiler) buildStartDIFCProxyStepYAML(data *WorkflowData) string { effectiveToken := getEffectiveGitHubToken(customGitHubToken) // Build the simplified guard policy JSON (static fields only) - policyJSON := getDIFCProxyPolicyJSON(githubTool) + // (plus reaction fields when integrity-reactions feature flag is enabled) + ensureDefaultMCPGatewayConfig(data) + policyJSON := getDIFCProxyPolicyJSON(githubTool, data, data.SandboxConfig.MCP) if policyJSON == "" { difcProxyLog.Print("Could not build DIFC proxy policy JSON, skipping proxy start") return "" @@ -232,7 +241,6 @@ func (c *Compiler) buildStartDIFCProxyStepYAML(data *WorkflowData) string { // Resolve the container image from the MCP gateway configuration // (proxy uses the same image as the gateway, just in "proxy" mode) - ensureDefaultMCPGatewayConfig(data) containerImage := resolveProxyContainerImage(data.SandboxConfig.MCP) var sb strings.Builder @@ -379,18 +387,18 @@ func (c *Compiler) buildStartCliProxyStepYAML(data *WorkflowData) string { customGitHubToken := getGitHubToken(githubTool) effectiveToken := getEffectiveGitHubToken(customGitHubToken) - // Build the guard policy JSON (static fields only). + // Build the guard policy JSON (static fields only, plus reaction fields when enabled). // The CLI proxy requires a policy to forward requests — without one, all API // calls return HTTP 503 ("proxy enforcement not configured"). Use the default // permissive policy when no guard policy is configured in the frontmatter. - policyJSON := getDIFCProxyPolicyJSON(githubTool) + ensureDefaultMCPGatewayConfig(data) + policyJSON := getDIFCProxyPolicyJSON(githubTool, data, data.SandboxConfig.MCP) if policyJSON == "" { policyJSON = defaultCliProxyPolicyJSON difcProxyLog.Print("No guard policy configured, using default CLI proxy policy") } // Resolve the container image from the MCP gateway configuration - ensureDefaultMCPGatewayConfig(data) containerImage := resolveProxyContainerImage(data.SandboxConfig.MCP) var sb strings.Builder diff --git a/pkg/workflow/compiler_difc_proxy_test.go b/pkg/workflow/compiler_difc_proxy_test.go index 505c10c2f99..006c8e022e7 100644 --- a/pkg/workflow/compiler_difc_proxy_test.go +++ b/pkg/workflow/compiler_difc_proxy_test.go @@ -262,7 +262,7 @@ func TestGetDIFCProxyPolicyJSON(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := getDIFCProxyPolicyJSON(tt.githubTool) + got := getDIFCProxyPolicyJSON(tt.githubTool, nil, nil) if tt.expectEmpty { assert.Empty(t, got, "policy JSON should be empty for: %s", tt.name) diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 12c365ba05e..f510cc0a733 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -112,6 +112,15 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) return nil, fmt.Errorf("%s: %w", cleanPath, err) } + // Validate integrity-reactions feature configuration + var gatewayConfig *MCPGatewayRuntimeConfig + if workflowData.SandboxConfig != nil { + gatewayConfig = workflowData.SandboxConfig.MCP + } + if err := validateIntegrityReactions(workflowData.ParsedTools, workflowData.Name, workflowData, gatewayConfig); err != nil { + return nil, fmt.Errorf("%s: %w", cleanPath, err) + } + // Use shared action cache and resolver from the compiler actionCache, actionResolver := c.getSharedActionResolver() workflowData.ActionCache = actionCache diff --git a/pkg/workflow/compiler_string_api.go b/pkg/workflow/compiler_string_api.go index 1597306844e..0a9982d4655 100644 --- a/pkg/workflow/compiler_string_api.go +++ b/pkg/workflow/compiler_string_api.go @@ -150,6 +150,15 @@ func (c *Compiler) ParseWorkflowString(content string, virtualPath string) (*Wor return nil, fmt.Errorf("%s: %w", cleanPath, err) } + // Validate integrity-reactions feature configuration + var gatewayConfig *MCPGatewayRuntimeConfig + if workflowData.SandboxConfig != nil { + gatewayConfig = workflowData.SandboxConfig.MCP + } + if err := validateIntegrityReactions(workflowData.ParsedTools, workflowData.Name, workflowData, gatewayConfig); err != nil { + return nil, fmt.Errorf("%s: %w", cleanPath, err) + } + // Setup action cache and resolver actionCache, actionResolver := c.getSharedActionResolver() workflowData.ActionCache = actionCache diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index b041fad2fd0..4764af24d45 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -68,6 +68,7 @@ import ( "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/semverutil" ) var githubConfigLog = logger.New("workflow:mcp_github_config") @@ -287,6 +288,95 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { return nil } +// DefaultEndorsementReactions are the default endorsement reactions injected when the +// integrity-reactions feature flag is enabled but no explicit endorsement-reactions are set. +var DefaultEndorsementReactions = []string{"THUMBS_UP", "HEART"} + +// DefaultDisapprovalReactions are the default disapproval reactions injected when the +// integrity-reactions feature flag is enabled but no explicit disapproval-reactions are set. +var DefaultDisapprovalReactions = []string{"THUMBS_DOWN", "CONFUSED"} + +// hasReactionFieldsInToolConfig returns true if any reaction-based integrity fields are +// explicitly set in the raw tool configuration map. +func hasReactionFieldsInToolConfig(toolConfig map[string]any) bool { + _, hasEndorsement := toolConfig["endorsement-reactions"] + _, hasDisapproval := toolConfig["disapproval-reactions"] + _, hasDisapprovalIntegrity := toolConfig["disapproval-integrity"] + _, hasEndorserMin := toolConfig["endorser-min-integrity"] + return hasEndorsement || hasDisapproval || hasDisapprovalIntegrity || hasEndorserMin +} + +// injectIntegrityReactionFields adds endorsement-reactions, disapproval-reactions, +// disapproval-integrity, and endorser-min-integrity into an existing allow-only policy +// map when the integrity-reactions feature flag is enabled and the MCPG version supports it. +// +// This function is used exclusively for proxy mode (DIFC proxy / CLI proxy). Reaction-based +// integrity is not supported in MCP gateway mode because the GitHub MCP server protocol does +// not expose reaction author information, which is required for integrity decisions. +// +// - policy is the inner allow-only map (not the outer allow-only wrapper). +// - toolConfig is the raw github tool configuration map. +// - data contains workflow data including feature flags used to check if integrity-reactions is enabled. +// - gatewayConfig contains MCP gateway version configuration used to version-gate the injection. +// +// When the feature flag is enabled and endorsement-reactions or disapproval-reactions are +// not explicitly set in toolConfig, sensible defaults are injected: +// - endorsement-reactions: ["THUMBS_UP", "HEART"] +// - disapproval-reactions: ["THUMBS_DOWN", "CONFUSED"] +// +// No-op when the feature flag is disabled or the MCPG version is too old. +func injectIntegrityReactionFields(policy map[string]any, toolConfig map[string]any, data *WorkflowData, gatewayConfig *MCPGatewayRuntimeConfig) { + if !isFeatureEnabled(constants.IntegrityReactionsFeatureFlag, data) { + return + } + if !mcpgSupportsIntegrityReactions(gatewayConfig) { + return + } + if endorsement, ok := toolConfig["endorsement-reactions"]; ok { + policy["endorsement-reactions"] = endorsement + } else { + policy["endorsement-reactions"] = DefaultEndorsementReactions + } + if disapproval, ok := toolConfig["disapproval-reactions"]; ok { + policy["disapproval-reactions"] = disapproval + } else { + policy["disapproval-reactions"] = DefaultDisapprovalReactions + } + if disapprovalIntegrity, ok := toolConfig["disapproval-integrity"]; ok { + policy["disapproval-integrity"] = disapprovalIntegrity + } + if endorserMinIntegrity, ok := toolConfig["endorser-min-integrity"]; ok { + policy["endorser-min-integrity"] = endorserMinIntegrity + } +} + +// mcpgSupportsIntegrityReactions returns true when the effective MCPG version supports +// endorsement-reactions and disapproval-reactions in the allow-only policy (>= v0.2.18). +// +// Special cases: +// - gatewayConfig is nil or has no Version: use DefaultMCPGatewayVersion for comparison. +// - "latest": always returns true (latest is always a new release). +// - Any semver string >= MCPGIntegrityReactionsMinVersion: returns true. +// - Any semver string < MCPGIntegrityReactionsMinVersion: returns false. +// - Non-semver string (e.g. a branch name): returns false (conservative). +func mcpgSupportsIntegrityReactions(gatewayConfig *MCPGatewayRuntimeConfig) bool { + var version string + if gatewayConfig != nil && gatewayConfig.Version != "" { + version = gatewayConfig.Version + } else { + // No override → use the default version for comparison. + version = string(constants.DefaultMCPGatewayVersion) + } + + // "latest" means the newest release — always supports the field. + if strings.EqualFold(version, "latest") { + return true + } + + minVersion := string(constants.MCPGIntegrityReactionsMinVersion) + return semverutil.Compare(version, minVersion) >= 0 +} + // 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 with accept field derived from repos according to these rules: diff --git a/pkg/workflow/mcp_renderer_github.go b/pkg/workflow/mcp_renderer_github.go index 6d44b394c7a..cd33632ede4 100644 --- a/pkg/workflow/mcp_renderer_github.go +++ b/pkg/workflow/mcp_renderer_github.go @@ -3,10 +3,12 @@ package workflow import ( "encoding/json" "fmt" + "os" "strconv" "strings" "time" + "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/constants" ) @@ -23,6 +25,20 @@ func (r *MCPConfigRendererUnified) RenderGitHubMCP(yaml *strings.Builder, github // guard policy is configured and no GitHub App token is in use. // The determine-automatic-lockdown step outputs min_integrity and repos for public repos. explicitGuardPolicies := getGitHubGuardPolicies(githubTool) + // Integrity reaction fields are only supported in proxy mode (DIFC/CLI proxy), + // not in gateway mode. The MCP gateway cannot identify reaction authors because + // the GitHub MCP server protocol does not expose that information. Warn if the + // user configured reactions with the gateway path. + if isFeatureEnabled(constants.IntegrityReactionsFeatureFlag, workflowData) { + if toolConfig, ok := githubTool.(map[string]any); ok { + if hasReactionFieldsInToolConfig(toolConfig) { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage( + "integrity-reactions: endorsement/disapproval reactions are ignored in MCP gateway mode because "+ + "reaction authors cannot be identified from the GitHub MCP server. Reactions are only enforced "+ + "in proxy mode (DIFC proxy / CLI proxy).")) + } + } + } shouldUseStepOutputForGuardPolicy := len(explicitGuardPolicies) == 0 && !hasGitHubApp(githubTool) toolsets := getGitHubToolsets(githubTool) diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index b33e05def0f..b4ce858d498 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -331,6 +331,34 @@ func parseGitHubTool(val any) *GitHubToolConfig { } } + // Parse reaction-based integrity fields (requires integrity-reactions feature flag + MCPG >= v0.2.18) + if endorsementReactions, ok := configMap["endorsement-reactions"].([]any); ok { + config.EndorsementReactions = make([]string, 0, len(endorsementReactions)) + for _, item := range endorsementReactions { + if str, ok := item.(string); ok { + config.EndorsementReactions = append(config.EndorsementReactions, str) + } + } + } else if endorsementReactions, ok := configMap["endorsement-reactions"].([]string); ok { + config.EndorsementReactions = endorsementReactions + } + if disapprovalReactions, ok := configMap["disapproval-reactions"].([]any); ok { + config.DisapprovalReactions = make([]string, 0, len(disapprovalReactions)) + for _, item := range disapprovalReactions { + if str, ok := item.(string); ok { + config.DisapprovalReactions = append(config.DisapprovalReactions, str) + } + } + } else if disapprovalReactions, ok := configMap["disapproval-reactions"].([]string); ok { + config.DisapprovalReactions = disapprovalReactions + } + if disapprovalIntegrity, ok := configMap["disapproval-integrity"].(string); ok { + config.DisapprovalIntegrity = disapprovalIntegrity + } + if endorserMinIntegrity, ok := configMap["endorser-min-integrity"].(string); ok { + config.EndorserMinIntegrity = endorserMinIntegrity + } + return config } diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index b7a0219539a..851cb444b10 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -317,6 +317,27 @@ type GitHubToolConfig struct { // resolves at runtime to a comma- or newline-separated list of approval label names. // Set when the approval-labels field is a string expression rather than a literal array. ApprovalLabelsExpr string `yaml:"-"` + // EndorsementReactions is an optional list of GitHub reaction types that promote content + // integrity to "approved" when added by maintainers. Only enforced in proxy mode (DIFC/CLI proxy); + // ignored in MCP gateway mode. Requires integrity-reactions feature flag and MCPG >= v0.2.18. + // When the feature flag is enabled and this field is not set, defaults to ["THUMBS_UP", "HEART"]. + // Valid values: THUMBS_UP, THUMBS_DOWN, HEART, HOORAY, CONFUSED, ROCKET, EYES, LAUGH + EndorsementReactions []string `yaml:"endorsement-reactions,omitempty"` + // DisapprovalReactions is an optional list of GitHub reaction types that demote content + // integrity when added by maintainers. Only enforced in proxy mode (DIFC/CLI proxy); + // ignored in MCP gateway mode. Requires integrity-reactions feature flag and MCPG >= v0.2.18. + // When the feature flag is enabled and this field is not set, defaults to ["THUMBS_DOWN", "CONFUSED"]. + // Valid values: THUMBS_UP, THUMBS_DOWN, HEART, HOORAY, CONFUSED, ROCKET, EYES, LAUGH + DisapprovalReactions []string `yaml:"disapproval-reactions,omitempty"` + // DisapprovalIntegrity is the integrity level assigned when a disapproval reaction is present. + // Optional, defaults to "none". Requires integrity-reactions feature flag and MCPG >= v0.2.18. + // Valid values: "none", "unapproved", "approved", "merged" + DisapprovalIntegrity string `yaml:"disapproval-integrity,omitempty"` + // EndorserMinIntegrity is the minimum integrity level required for an endorser (reactor) to + // promote content. Optional, defaults to "approved". Requires integrity-reactions feature flag + // and MCPG >= v0.2.18. + // Valid values: "approved", "unapproved", "merged" + EndorserMinIntegrity string `yaml:"endorser-min-integrity,omitempty"` } // PlaywrightToolConfig represents the configuration for the Playwright tool diff --git a/pkg/workflow/tools_validation.go b/pkg/workflow/tools_validation.go index ec1769bab0d..85209189c79 100644 --- a/pkg/workflow/tools_validation.go +++ b/pkg/workflow/tools_validation.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/parser" ) @@ -147,6 +148,112 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { return nil } +// validReactionContents is the set of valid GitHub ReactionContent enum values. +var validReactionContents = map[string]bool{ + "THUMBS_UP": true, + "THUMBS_DOWN": true, + "HEART": true, + "HOORAY": true, + "CONFUSED": true, + "ROCKET": true, + "EYES": true, + "LAUGH": true, +} + +// validDisapprovalIntegrityLevels is the set of valid integrity levels for disapproval-integrity. +var validDisapprovalIntegrityLevels = map[string]bool{ + "none": true, + "unapproved": true, + "approved": true, + "merged": true, +} + +// validEndorserMinIntegrityLevels is the set of valid integrity levels for endorser-min-integrity. +var validEndorserMinIntegrityLevels = map[string]bool{ + "unapproved": true, + "approved": true, + "merged": true, +} + +// validateIntegrityReactions validates the integrity-reactions feature configuration. +// It checks that: +// - endorsement-reactions and disapproval-reactions contain valid ReactionContent values +// - the integrity-reactions feature flag requires min-integrity to be set (defaults will be injected) +// - disapproval-integrity and endorser-min-integrity use valid integrity levels +// - the integrity-reactions feature flag requires MCPG >= v0.2.18 +func validateIntegrityReactions(tools *Tools, workflowName string, data *WorkflowData, gatewayConfig *MCPGatewayRuntimeConfig) error { + if tools == nil || tools.GitHub == nil { + return nil + } + + github := tools.GitHub + + hasEndorsementReactions := len(github.EndorsementReactions) > 0 + hasDisapprovalReactions := len(github.DisapprovalReactions) > 0 + hasDisapprovalIntegrity := github.DisapprovalIntegrity != "" + hasEndorserMinIntegrity := github.EndorserMinIntegrity != "" + hasExplicitReactionFields := hasEndorsementReactions || hasDisapprovalReactions || hasDisapprovalIntegrity || hasEndorserMinIntegrity + featureEnabled := isFeatureEnabled(constants.IntegrityReactionsFeatureFlag, data) + + // If none of the reaction fields are set and the feature flag is not enabled, nothing to validate + if !hasExplicitReactionFields && !featureEnabled { + return nil + } + + // Explicit reaction fields require the integrity-reactions feature flag + if hasExplicitReactionFields && !featureEnabled { + toolsValidationLog.Printf("Reaction fields present but integrity-reactions feature flag not enabled in workflow: %s", workflowName) + return errors.New("invalid guard policy: 'endorsement-reactions', 'disapproval-reactions', 'disapproval-integrity', and 'endorser-min-integrity' require the 'integrity-reactions' feature flag to be enabled. Add 'features: integrity-reactions: true' to your workflow") + } + + // Feature flag requires MCPG >= v0.2.18 + if !mcpgSupportsIntegrityReactions(gatewayConfig) { + version := string(constants.DefaultMCPGatewayVersion) + if gatewayConfig != nil && gatewayConfig.Version != "" { + version = gatewayConfig.Version + } + toolsValidationLog.Printf("integrity-reactions feature flag enabled but MCPG version %s < %s in workflow: %s", version, constants.MCPGIntegrityReactionsMinVersion, workflowName) + return fmt.Errorf("invalid guard policy: 'integrity-reactions' feature flag requires MCPG >= %s, but the configured version is %s. Update the MCP gateway version to use this feature", + constants.MCPGIntegrityReactionsMinVersion, version) + } + + // Feature flag requires min-integrity (defaults for reaction lists will be injected at compile time) + if github.MinIntegrity == "" { + toolsValidationLog.Printf("integrity-reactions feature flag enabled without min-integrity in workflow: %s", workflowName) + return errors.New("invalid guard policy: 'integrity-reactions' feature flag requires 'github.min-integrity' to be set") + } + + // Validate endorsement-reactions values (if explicitly provided) + for i, reaction := range github.EndorsementReactions { + if !validReactionContents[reaction] { + toolsValidationLog.Printf("Invalid endorsement-reactions value '%s' at index %d in workflow: %s", reaction, i, workflowName) + return fmt.Errorf("invalid guard policy: 'endorsement-reactions' contains invalid value '%s'. Valid values: THUMBS_UP, THUMBS_DOWN, HEART, HOORAY, CONFUSED, ROCKET, EYES, LAUGH", reaction) + } + } + + // Validate disapproval-reactions values (if explicitly provided) + for i, reaction := range github.DisapprovalReactions { + if !validReactionContents[reaction] { + toolsValidationLog.Printf("Invalid disapproval-reactions value '%s' at index %d in workflow: %s", reaction, i, workflowName) + return fmt.Errorf("invalid guard policy: 'disapproval-reactions' contains invalid value '%s'. Valid values: THUMBS_UP, THUMBS_DOWN, HEART, HOORAY, CONFUSED, ROCKET, EYES, LAUGH", reaction) + } + } + + // Validate disapproval-integrity value + if hasDisapprovalIntegrity && !validDisapprovalIntegrityLevels[github.DisapprovalIntegrity] { + toolsValidationLog.Printf("Invalid disapproval-integrity value '%s' in workflow: %s", github.DisapprovalIntegrity, workflowName) + return fmt.Errorf("invalid guard policy: 'disapproval-integrity' must be one of: 'none', 'unapproved', 'approved', 'merged'. Got: '%s'", github.DisapprovalIntegrity) + } + + // Validate endorser-min-integrity value + if hasEndorserMinIntegrity && !validEndorserMinIntegrityLevels[github.EndorserMinIntegrity] { + toolsValidationLog.Printf("Invalid endorser-min-integrity value '%s' in workflow: %s", github.EndorserMinIntegrity, workflowName) + return fmt.Errorf("invalid guard policy: 'endorser-min-integrity' must be one of: 'unapproved', 'approved', 'merged'. Got: '%s'", github.EndorserMinIntegrity) + } + + return nil +} + // validateReposScope validates the repos field in the guard policy func validateReposScope(repos any, workflowName string) error { // Case 1: String value ("all" or "public") diff --git a/pkg/workflow/tools_validation_test.go b/pkg/workflow/tools_validation_test.go index 856c32c63e8..6facbcc9625 100644 --- a/pkg/workflow/tools_validation_test.go +++ b/pkg/workflow/tools_validation_test.go @@ -636,3 +636,407 @@ func TestValidateReposScopeWithStringSlice(t *testing.T) { }) } } + +// TestMCPGSupportsIntegrityReactions verifies the version gate for integrity-reactions. +func TestMCPGSupportsIntegrityReactions(t *testing.T) { + tests := []struct { + name string + gatewayConfig *MCPGatewayRuntimeConfig + want bool + }{ + { + name: "nil gateway config uses default (v0.2.17, below min)", + gatewayConfig: nil, + // DefaultMCPGatewayVersion = "v0.2.17" < MCPGIntegrityReactionsMinVersion = "v0.2.18" + want: false, + }, + { + name: "empty version uses default (v0.2.17, below min)", + gatewayConfig: &MCPGatewayRuntimeConfig{Container: "ghcr.io/test/mcpg"}, + want: false, + }, + { + name: "version exactly at minimum (v0.2.18)", + gatewayConfig: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/test/mcpg", + Version: "v0.2.18", + }, + want: true, + }, + { + name: "version above minimum (v0.2.19)", + gatewayConfig: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/test/mcpg", + Version: "v0.2.19", + }, + want: true, + }, + { + name: "version below minimum (v0.2.17)", + gatewayConfig: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/test/mcpg", + Version: "v0.2.17", + }, + want: false, + }, + { + name: "version much higher (v1.0.0)", + gatewayConfig: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/test/mcpg", + Version: "v1.0.0", + }, + want: true, + }, + { + name: "latest always supported", + gatewayConfig: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/test/mcpg", + Version: "latest", + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mcpgSupportsIntegrityReactions(tt.gatewayConfig) + assert.Equal(t, tt.want, got, "mcpgSupportsIntegrityReactions result") + }) + } +} + +// TestValidateIntegrityReactions verifies validation of integrity-reactions fields. +func TestValidateIntegrityReactions(t *testing.T) { + // Gateway config with MCPG >= v0.2.18 (supports integrity reactions) + newGatewayConfig := &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/test/mcpg", + Version: "v0.2.18", + } + // Gateway config with MCPG < v0.2.18 (does not support integrity reactions) + oldGatewayConfig := &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/test/mcpg", + Version: "v0.2.17", + } + + makeDataWithFeature := func(enabled bool) *WorkflowData { + features := map[string]any{} + if enabled { + features["integrity-reactions"] = true + } + return &WorkflowData{Features: features} + } + + tests := []struct { + name string + tools *Tools + data *WorkflowData + gatewayConfig *MCPGatewayRuntimeConfig + shouldError bool + errorContains string + }{ + { + name: "nil tools is valid", + tools: nil, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: false, + }, + { + name: "no github tool is valid", + tools: &Tools{}, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: false, + }, + { + name: "no reaction fields is valid (feature disabled)", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + }, + }, + data: makeDataWithFeature(false), + gatewayConfig: newGatewayConfig, + shouldError: false, + }, + { + name: "valid endorsement and disapproval reactions", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + EndorsementReactions: []string{"THUMBS_UP", "HEART"}, + DisapprovalReactions: []string{"THUMBS_DOWN", "CONFUSED"}, + }, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: false, + }, + { + name: "valid with all optional fields", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + EndorsementReactions: []string{"THUMBS_UP"}, + DisapprovalReactions: []string{"THUMBS_DOWN"}, + DisapprovalIntegrity: "none", + EndorserMinIntegrity: "approved", + }, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: false, + }, + { + name: "reaction fields without feature flag", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + EndorsementReactions: []string{"THUMBS_UP"}, + }, + }, + data: makeDataWithFeature(false), + gatewayConfig: newGatewayConfig, + shouldError: true, + errorContains: "integrity-reactions", + }, + { + name: "reaction fields with old MCPG version", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + EndorsementReactions: []string{"THUMBS_UP"}, + }, + }, + data: makeDataWithFeature(true), + gatewayConfig: oldGatewayConfig, + shouldError: true, + errorContains: "v0.2.18", + }, + { + name: "reaction fields without min-integrity", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + EndorsementReactions: []string{"THUMBS_UP"}, + }, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: true, + errorContains: "min-integrity", + }, + { + name: "invalid endorsement reaction value", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + EndorsementReactions: []string{"INVALID_REACTION"}, + }, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: true, + errorContains: "INVALID_REACTION", + }, + { + name: "invalid disapproval reaction value", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + DisapprovalReactions: []string{"WAVE"}, + }, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: true, + errorContains: "WAVE", + }, + { + name: "invalid disapproval-integrity value", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + DisapprovalIntegrity: "invalid-level", + }, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: true, + errorContains: "invalid-level", + }, + { + name: "invalid endorser-min-integrity value", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + EndorserMinIntegrity: "none", + }, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: true, + errorContains: "none", + }, + { + name: "only disapproval-integrity (no reaction arrays) with min-integrity is valid", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + DisapprovalIntegrity: "none", + }, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: false, + }, + { + name: "feature flag enabled with min-integrity but no explicit reactions — valid (defaults used)", + tools: &Tools{ + GitHub: &GitHubToolConfig{ + MinIntegrity: GitHubIntegrityApproved, + }, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: false, + }, + { + name: "feature flag enabled without min-integrity — error even without explicit reactions", + tools: &Tools{ + GitHub: &GitHubToolConfig{}, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + shouldError: true, + errorContains: "min-integrity", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateIntegrityReactions(tt.tools, "test-workflow", tt.data, tt.gatewayConfig) + + if tt.shouldError { + require.Error(t, err, "Expected error for: %s", tt.name) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains, "Error should mention: %s", tt.errorContains) + } + } else { + assert.NoError(t, err, "Expected no error for: %s", tt.name) + } + }) + } +} + +// TestGetDIFCProxyPolicyJSONWithReactions verifies that reaction fields are injected +// into the DIFC proxy policy when the integrity-reactions feature flag is enabled and +// the MCPG version supports it. +func TestGetDIFCProxyPolicyJSONWithReactions(t *testing.T) { + newGatewayConfig := &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/test/mcpg", + Version: "v0.2.18", + } + oldGatewayConfig := &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/test/mcpg", + Version: "v0.2.17", + } + + makeDataWithFeature := func(enabled bool) *WorkflowData { + features := map[string]any{} + if enabled { + features["integrity-reactions"] = true + } + return &WorkflowData{Features: features} + } + + tests := []struct { + name string + githubTool any + data *WorkflowData + gatewayConfig *MCPGatewayRuntimeConfig + expectedContains []string + expectedAbsent []string + }{ + { + name: "reactions injected when feature enabled and MCPG supports it", + githubTool: map[string]any{ + "min-integrity": "approved", + "endorsement-reactions": []any{"THUMBS_UP", "HEART"}, + "disapproval-reactions": []any{"THUMBS_DOWN"}, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + expectedContains: []string{`"endorsement-reactions"`, `"disapproval-reactions"`, "THUMBS_UP", "HEART", "THUMBS_DOWN"}, + }, + { + name: "reactions not injected when feature disabled", + githubTool: map[string]any{ + "min-integrity": "approved", + "endorsement-reactions": []any{"THUMBS_UP"}, + }, + data: makeDataWithFeature(false), + gatewayConfig: newGatewayConfig, + expectedAbsent: []string{"endorsement-reactions"}, + }, + { + name: "reactions not injected when MCPG version too old", + githubTool: map[string]any{ + "min-integrity": "approved", + "endorsement-reactions": []any{"THUMBS_UP"}, + }, + data: makeDataWithFeature(true), + gatewayConfig: oldGatewayConfig, + expectedAbsent: []string{"endorsement-reactions"}, + }, + { + name: "optional reaction fields injected when present", + githubTool: map[string]any{ + "min-integrity": "approved", + "endorsement-reactions": []any{"THUMBS_UP"}, + "disapproval-integrity": "none", + "endorser-min-integrity": "approved", + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + expectedContains: []string{`"disapproval-integrity"`, `"endorser-min-integrity"`}, + }, + { + name: "defaults injected when feature enabled but no explicit reactions", + githubTool: map[string]any{ + "min-integrity": "approved", + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + expectedContains: []string{ + `"endorsement-reactions"`, "THUMBS_UP", "HEART", + `"disapproval-reactions"`, "THUMBS_DOWN", "CONFUSED", + }, + }, + { + name: "explicit reactions override defaults", + githubTool: map[string]any{ + "min-integrity": "approved", + "endorsement-reactions": []any{"ROCKET"}, + "disapproval-reactions": []any{"EYES"}, + }, + data: makeDataWithFeature(true), + gatewayConfig: newGatewayConfig, + expectedContains: []string{"ROCKET", "EYES"}, + expectedAbsent: []string{"HEART", "CONFUSED"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getDIFCProxyPolicyJSON(tt.githubTool, tt.data, tt.gatewayConfig) + require.NotEmpty(t, got, "policy JSON should not be empty") + + for _, s := range tt.expectedContains { + assert.Contains(t, got, s, "policy JSON should contain %q", s) + } + for _, s := range tt.expectedAbsent { + assert.NotContains(t, got, s, "policy JSON should NOT contain %q", s) + } + }) + } +}