From f48417b109f0a362823ee73f7f5a0f0285ec0c02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:17:17 +0000 Subject: [PATCH 1/7] Add blocked-users and approval-labels to tools.github guard policy Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/3fc56a36-fce8-4d6d-9b0b-c81286db1f5f --- pkg/cli/compile_guard_policy_test.go | 49 +++++++++++ pkg/parser/schemas/main_workflow_schema.json | 18 ++++ pkg/workflow/mcp_github_config.go | 11 ++- pkg/workflow/tools_parser.go | 20 +++++ pkg/workflow/tools_types.go | 8 +- pkg/workflow/tools_validation.go | 24 ++++++ pkg/workflow/tools_validation_test.go | 89 ++++++++++++++++++++ 7 files changed, 217 insertions(+), 2 deletions(-) diff --git a/pkg/cli/compile_guard_policy_test.go b/pkg/cli/compile_guard_policy_test.go index 9dc15a850e8..0e61cf436d6 100644 --- a/pkg/cli/compile_guard_policy_test.go +++ b/pkg/cli/compile_guard_policy_test.go @@ -188,3 +188,52 @@ This workflow uses min-integrity without specifying repos. assert.Contains(t, lockFileContent, `"guard-policies": {`+"\n"+` "allow-only": {`+"\n"+` "min-integrity": "approved",`+"\n"+` "repos": "all"`, "Compiled lock file must include repos=all and min-integrity=approved in the guard-policies allow-only block") } + +// TestGuardPolicyBlockedUsersApprovalLabelsCompiledOutput verifies that blocked-users and +// approval-labels are written into the compiled guard-policies allow-only block. +func TestGuardPolicyBlockedUsersApprovalLabelsCompiledOutput(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +tools: + github: + allowed-repos: + - myorg/myrepo + min-integrity: approved + blocked-users: + - spam-bot + - compromised-user + approval-labels: + - human-reviewed + - safe-for-agent +--- + +# Guard Policy Test + +This workflow uses blocked-users and approval-labels. +` + + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-guard-policy-blocked.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "Failed to write workflow file") + + compiler := workflow.NewCompiler() + err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + require.NoError(t, err, "Expected compilation to succeed") + + lockFilePath := filepath.Join(tmpDir, "test-guard-policy-blocked.lock.yml") + lockFileBytes, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "Failed to read compiled lock file") + + lockFileContent := string(lockFileBytes) + assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users in the guard-policies allow-only block") + assert.Contains(t, lockFileContent, `"spam-bot"`, "Compiled lock file must include spam-bot in blocked-users") + assert.Contains(t, lockFileContent, `"compromised-user"`, "Compiled lock file must include compromised-user in blocked-users") + assert.Contains(t, lockFileContent, `"approval-labels"`, "Compiled lock file must include approval-labels in the guard-policies allow-only block") + assert.Contains(t, lockFileContent, `"human-reviewed"`, "Compiled lock file must include human-reviewed in approval-labels") + assert.Contains(t, lockFileContent, `"safe-for-agent"`, "Compiled lock file must include safe-for-agent in approval-labels") +} diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index ba3891234d2..8dcae8f1aa8 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3206,6 +3206,24 @@ "description": "Guard policy: minimum required integrity level for repository access. Restricts the agent to users with at least the specified permission level.", "enum": ["none", "unapproved", "approved", "merged"] }, + "blocked-users": { + "type": "array", + "description": "Guard policy: GitHub usernames whose content is unconditionally blocked. Items from these users receive 'blocked' integrity (below 'none') and are always denied, even when 'min-integrity' is 'none'. Cannot be overridden by 'approval-labels'. Requires 'min-integrity' to be set.", + "items": { + "type": "string", + "description": "GitHub username to block" + }, + "minItems": 1 + }, + "approval-labels": { + "type": "array", + "description": "Guard policy: GitHub label names that promote a content item's effective integrity to 'approved' when present. Enables human-review gates where a maintainer labels an item to allow it through. Uses max(base, approved) so it never lowers integrity. Does not override 'blocked-users'. Requires 'min-integrity' to be set.", + "items": { + "type": "string", + "description": "GitHub label name" + }, + "minItems": 1 + }, "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/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 3a86a3e2465..fd40a2c305d 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -242,7 +242,8 @@ func getGitHubAllowedTools(githubTool any) []string { } // getGitHubGuardPolicies extracts guard policies from GitHub tool configuration. -// It reads the flat allowed-repos/repos/min-integrity fields and wraps them for MCP gateway rendering. +// It reads the flat allowed-repos/repos/min-integrity/blocked-users/approval-labels fields +// and wraps them for MCP gateway rendering. // When min-integrity is set but allowed-repos is not, repos defaults to "all" because the MCP // Gateway requires repos to be present in the allow-only policy. // Note: repos-only (without min-integrity) is rejected earlier by validateGitHubGuardPolicy, @@ -256,6 +257,8 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { repos, hasRepos = toolConfig["repos"] } integrity, hasIntegrity := toolConfig["min-integrity"] + blockedUsers, hasBlockedUsers := toolConfig["blocked-users"] + approvalLabels, hasApprovalLabels := toolConfig["approval-labels"] if hasRepos || hasIntegrity { policy := map[string]any{} if hasRepos { @@ -268,6 +271,12 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { if hasIntegrity { policy["min-integrity"] = integrity } + if hasBlockedUsers { + policy["blocked-users"] = blockedUsers + } + if hasApprovalLabels { + policy["approval-labels"] = approvalLabels + } return map[string]any{ "allow-only": policy, } diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index ae9444288c1..7b0f58c15ea 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -249,6 +249,26 @@ func parseGitHubTool(val any) *GitHubToolConfig { if integrity, ok := configMap["min-integrity"].(string); ok { config.MinIntegrity = GitHubIntegrityLevel(integrity) } + if blockedUsers, ok := configMap["blocked-users"].([]any); ok { + config.BlockedUsers = make([]string, 0, len(blockedUsers)) + for _, item := range blockedUsers { + if str, ok := item.(string); ok { + config.BlockedUsers = append(config.BlockedUsers, str) + } + } + } else if blockedUsers, ok := configMap["blocked-users"].([]string); ok { + config.BlockedUsers = blockedUsers + } + if approvalLabels, ok := configMap["approval-labels"].([]any); ok { + config.ApprovalLabels = make([]string, 0, len(approvalLabels)) + for _, item := range approvalLabels { + if str, ok := item.(string); ok { + config.ApprovalLabels = append(config.ApprovalLabels, str) + } + } + } else if approvalLabels, ok := configMap["approval-labels"].([]string); ok { + config.ApprovalLabels = approvalLabels + } return config } diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index 0322093286b..5ca3814f74d 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -299,8 +299,14 @@ type GitHubToolConfig struct { AllowedRepos GitHubReposScope `yaml:"allowed-repos,omitempty"` // Repos is deprecated. Use AllowedRepos (yaml:"allowed-repos") instead. Repos GitHubReposScope `yaml:"repos,omitempty"` - // MinIntegrity defines the minimum integrity level required: "none", "reader", "writer", "merged" + // MinIntegrity defines the minimum integrity level required: "none", "unapproved", "approved", "merged" MinIntegrity GitHubIntegrityLevel `yaml:"min-integrity,omitempty"` + // BlockedUsers is an optional list of GitHub usernames whose content is unconditionally blocked. + // Items from these users receive "blocked" integrity (below "none") and are always denied. + BlockedUsers []string `yaml:"blocked-users,omitempty"` + // ApprovalLabels is an optional list of GitHub label names that promote a content item's + // effective integrity to "approved" when present. Does not override BlockedUsers. + ApprovalLabels []string `yaml:"approval-labels,omitempty"` } // PlaywrightToolConfig represents the configuration for the Playwright tool diff --git a/pkg/workflow/tools_validation.go b/pkg/workflow/tools_validation.go index 0582d093de8..5f6c74e91bb 100644 --- a/pkg/workflow/tools_validation.go +++ b/pkg/workflow/tools_validation.go @@ -73,6 +73,14 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { // AllowedRepos is populated from either 'allowed-repos' (preferred) or deprecated 'repos' during parsing hasRepos := github.AllowedRepos != nil hasMinIntegrity := github.MinIntegrity != "" + hasBlockedUsers := len(github.BlockedUsers) > 0 + hasApprovalLabels := len(github.ApprovalLabels) > 0 + + // blocked-users and approval-labels require a guard policy (min-integrity) + if (hasBlockedUsers || hasApprovalLabels) && !hasMinIntegrity { + toolsValidationLog.Printf("blocked-users/approval-labels without guard policy in workflow: %s", workflowName) + return errors.New("invalid guard policy: 'github.blocked-users' and 'github.approval-labels' require 'github.min-integrity' to be set") + } // No guard policy fields present - nothing to validate if !hasRepos && !hasMinIntegrity { @@ -109,6 +117,22 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { return errors.New("invalid guard policy: 'github.min-integrity' must be one of: 'none', 'unapproved', 'approved', 'merged'. Got: '" + string(github.MinIntegrity) + "'") } + // Validate blocked-users (must be non-empty strings) + for i, user := range github.BlockedUsers { + if user == "" { + toolsValidationLog.Printf("Empty blocked-users entry at index %d in workflow: %s", i, workflowName) + return errors.New("invalid guard policy: 'github.blocked-users' entries must not be empty strings") + } + } + + // Validate approval-labels (must be non-empty strings) + for i, label := range github.ApprovalLabels { + if label == "" { + toolsValidationLog.Printf("Empty approval-labels entry at index %d in workflow: %s", i, workflowName) + return errors.New("invalid guard policy: 'github.approval-labels' entries must not be empty strings") + } + } + return nil } diff --git a/pkg/workflow/tools_validation_test.go b/pkg/workflow/tools_validation_test.go index 2e3702fbc56..a227f5b52d5 100644 --- a/pkg/workflow/tools_validation_test.go +++ b/pkg/workflow/tools_validation_test.go @@ -374,6 +374,95 @@ func TestValidateGitHubGuardPolicy(t *testing.T) { shouldError: true, errorMsg: "must be in format", }, + { + name: "valid guard policy with blocked-users", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "unapproved", + "blocked-users": []string{"spam-bot", "compromised-user"}, + }, + }, + shouldError: false, + }, + { + name: "valid guard policy with approval-labels", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "approved", + "approval-labels": []string{"human-reviewed", "safe-for-agent"}, + }, + }, + shouldError: false, + }, + { + name: "valid guard policy with both blocked-users and approval-labels", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": []any{"myorg/*"}, + "min-integrity": "approved", + "blocked-users": []string{"spam-bot"}, + "approval-labels": []string{"human-reviewed"}, + }, + }, + shouldError: false, + }, + { + name: "blocked-users without min-integrity fails", + toolsMap: map[string]any{ + "github": map[string]any{ + "blocked-users": []string{"spam-bot"}, + }, + }, + shouldError: true, + errorMsg: "'github.blocked-users' and 'github.approval-labels' require 'github.min-integrity'", + }, + { + name: "approval-labels without min-integrity fails", + toolsMap: map[string]any{ + "github": map[string]any{ + "approval-labels": []string{"human-reviewed"}, + }, + }, + shouldError: true, + errorMsg: "'github.blocked-users' and 'github.approval-labels' require 'github.min-integrity'", + }, + { + name: "blocked-users with empty string entry fails", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "unapproved", + "blocked-users": []string{"valid-user", ""}, + }, + }, + shouldError: true, + errorMsg: "'github.blocked-users' entries must not be empty strings", + }, + { + name: "approval-labels with empty string entry fails", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "approved", + "approval-labels": []string{""}, + }, + }, + shouldError: true, + errorMsg: "'github.approval-labels' entries must not be empty strings", + }, + { + name: "blocked-users with allowed-repos but without min-integrity fails", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "blocked-users": []string{"spam-bot"}, + }, + }, + shouldError: true, + errorMsg: "'github.blocked-users' and 'github.approval-labels' require 'github.min-integrity'", + }, } for _, tt := range tests { From 97458256899d7cc37df744cccb71f62b6a239f7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:57:23 +0000 Subject: [PATCH 2/7] Support GitHub Actions expression and comma/newline-separated string for blocked-users and approval-labels Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/a04e7fe3-fdc1-4b49-b988-96c3ee0ee304 --- pkg/cli/compile_guard_policy_test.go | 84 ++++++++++++++++++++ pkg/parser/schemas/main_workflow_schema.json | 46 +++++++---- pkg/workflow/tools_parser.go | 46 +++++++++++ pkg/workflow/tools_types.go | 8 ++ pkg/workflow/tools_validation.go | 10 ++- pkg/workflow/tools_validation_test.go | 54 +++++++++++++ 6 files changed, 230 insertions(+), 18 deletions(-) diff --git a/pkg/cli/compile_guard_policy_test.go b/pkg/cli/compile_guard_policy_test.go index 0e61cf436d6..b574e74a05e 100644 --- a/pkg/cli/compile_guard_policy_test.go +++ b/pkg/cli/compile_guard_policy_test.go @@ -237,3 +237,87 @@ This workflow uses blocked-users and approval-labels. assert.Contains(t, lockFileContent, `"human-reviewed"`, "Compiled lock file must include human-reviewed in approval-labels") assert.Contains(t, lockFileContent, `"safe-for-agent"`, "Compiled lock file must include safe-for-agent in approval-labels") } + +// TestGuardPolicyBlockedUsersExpressionCompiledOutput verifies that blocked-users as a GitHub +// Actions expression is passed through as a string in the compiled guard-policies block. +func TestGuardPolicyBlockedUsersExpressionCompiledOutput(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +tools: + github: + allowed-repos: all + min-integrity: unapproved + blocked-users: "${{ vars.BLOCKED_USERS }}" + approval-labels: "${{ vars.APPROVAL_LABELS }}" +--- + +# Guard Policy Test + +This workflow passes blocked-users and approval-labels as expressions. +` + + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-guard-policy-expr.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "Failed to write workflow file") + + compiler := workflow.NewCompiler() + err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + require.NoError(t, err, "Expected compilation to succeed") + + lockFilePath := filepath.Join(tmpDir, "test-guard-policy-expr.lock.yml") + lockFileBytes, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "Failed to read compiled lock file") + + lockFileContent := string(lockFileBytes) + // Expressions should be passed through as string values in the JSON config. + assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users") + assert.Contains(t, lockFileContent, `vars.BLOCKED_USERS`, "Compiled lock file must preserve the blocked-users expression") + assert.Contains(t, lockFileContent, `"approval-labels"`, "Compiled lock file must include approval-labels") + assert.Contains(t, lockFileContent, `vars.APPROVAL_LABELS`, "Compiled lock file must preserve the approval-labels expression") +} + +// TestGuardPolicyBlockedUsersCommaSeparatedCompiledOutput verifies that a static +// comma-separated blocked-users string is split at compile time. +func TestGuardPolicyBlockedUsersCommaSeparatedCompiledOutput(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +tools: + github: + allowed-repos: all + min-integrity: unapproved + blocked-users: "spam-bot, compromised-user" +--- + +# Guard Policy Test + +This workflow passes blocked-users as a comma-separated string. +` + + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-guard-policy-csv.md") + err := os.WriteFile(workflowPath, []byte(workflowContent), 0644) + require.NoError(t, err, "Failed to write workflow file") + + compiler := workflow.NewCompiler() + err = CompileWorkflowWithValidation(compiler, workflowPath, false, false, false, false, false, false) + require.NoError(t, err, "Expected compilation to succeed") + + lockFilePath := filepath.Join(tmpDir, "test-guard-policy-csv.lock.yml") + lockFileBytes, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "Failed to read compiled lock file") + + lockFileContent := string(lockFileBytes) + // Static comma-separated strings should be split into a JSON array at compile time. + assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users") + assert.Contains(t, lockFileContent, `"spam-bot"`, "Compiled lock file must split spam-bot from comma-separated string") + assert.Contains(t, lockFileContent, `"compromised-user"`, "Compiled lock file must split compromised-user from comma-separated string") +} diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 8dcae8f1aa8..fc1dff4be0d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3207,22 +3207,40 @@ "enum": ["none", "unapproved", "approved", "merged"] }, "blocked-users": { - "type": "array", - "description": "Guard policy: GitHub usernames whose content is unconditionally blocked. Items from these users receive 'blocked' integrity (below 'none') and are always denied, even when 'min-integrity' is 'none'. Cannot be overridden by 'approval-labels'. Requires 'min-integrity' to be set.", - "items": { - "type": "string", - "description": "GitHub username to block" - }, - "minItems": 1 + "description": "Guard policy: GitHub usernames whose content is unconditionally blocked. Items from these users receive 'blocked' integrity (below 'none') and are always denied, even when 'min-integrity' is 'none'. Cannot be overridden by 'approval-labels'. Requires 'min-integrity' to be set. Accepts an array of usernames, a comma-separated string, a newline-separated string, or a GitHub Actions expression (e.g. '${{ vars.BLOCKED_USERS }}').", + "oneOf": [ + { + "type": "array", + "description": "Array of GitHub usernames to block", + "items": { + "type": "string", + "description": "GitHub username to block" + }, + "minItems": 1 + }, + { + "type": "string", + "description": "Comma- or newline-separated list of usernames, or a GitHub Actions expression resolving to such a list (e.g. '${{ vars.BLOCKED_USERS }}')" + } + ] }, "approval-labels": { - "type": "array", - "description": "Guard policy: GitHub label names that promote a content item's effective integrity to 'approved' when present. Enables human-review gates where a maintainer labels an item to allow it through. Uses max(base, approved) so it never lowers integrity. Does not override 'blocked-users'. Requires 'min-integrity' to be set.", - "items": { - "type": "string", - "description": "GitHub label name" - }, - "minItems": 1 + "description": "Guard policy: GitHub label names that promote a content item's effective integrity to 'approved' when present. Enables human-review gates where a maintainer labels an item to allow it through. Uses max(base, approved) so it never lowers integrity. Does not override 'blocked-users'. Requires 'min-integrity' to be set. Accepts an array of label names, a comma-separated string, a newline-separated string, or a GitHub Actions expression (e.g. '${{ vars.APPROVAL_LABELS }}').", + "oneOf": [ + { + "type": "array", + "description": "Array of GitHub label names", + "items": { + "type": "string", + "description": "GitHub label name" + }, + "minItems": 1 + }, + { + "type": "string", + "description": "Comma- or newline-separated list of label names, or a GitHub Actions expression resolving to such a list (e.g. '${{ vars.APPROVAL_LABELS }}')" + } + ] }, "github-app": { "$ref": "#/$defs/github_app", diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index 7b0f58c15ea..e8f2de7b559 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -55,6 +55,7 @@ import ( "maps" "os" "strconv" + "strings" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" @@ -62,6 +63,31 @@ import ( var toolsParserLog = logger.New("workflow:tools_parser") +// parseCommaSeparatedOrNewlineList splits a string by commas and/or newlines, +// trims surrounding whitespace from each item, and discards empty items. +func parseCommaSeparatedOrNewlineList(s string) []string { + // Normalize newlines to commas, then split on comma. + normalized := strings.ReplaceAll(s, "\n", ",") + parts := strings.Split(normalized, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +// toAnySlice converts a []string to []any for storage in a map[string]any. +func toAnySlice(ss []string) []any { + out := make([]any, len(ss)) + for i, s := range ss { + out[i] = s + } + return out +} + // NewTools creates a new Tools instance from a map func NewTools(toolsMap map[string]any) *Tools { toolsParserLog.Printf("Creating tools configuration from map with %d entries", len(toolsMap)) @@ -258,6 +284,16 @@ func parseGitHubTool(val any) *GitHubToolConfig { } } else if blockedUsers, ok := configMap["blocked-users"].([]string); ok { config.BlockedUsers = blockedUsers + } else if blockedUsersStr, ok := configMap["blocked-users"].(string); ok { + if isGitHubActionsExpression(blockedUsersStr) { + // GitHub Actions expression: store as-is; raw map retains the string for JSON rendering. + config.BlockedUsersExpr = blockedUsersStr + } else { + // Static comma/newline-separated string: parse at compile time. + parsed := parseCommaSeparatedOrNewlineList(blockedUsersStr) + config.BlockedUsers = parsed + configMap["blocked-users"] = toAnySlice(parsed) // normalize raw map for JSON rendering + } } if approvalLabels, ok := configMap["approval-labels"].([]any); ok { config.ApprovalLabels = make([]string, 0, len(approvalLabels)) @@ -268,6 +304,16 @@ func parseGitHubTool(val any) *GitHubToolConfig { } } else if approvalLabels, ok := configMap["approval-labels"].([]string); ok { config.ApprovalLabels = approvalLabels + } else if approvalLabelsStr, ok := configMap["approval-labels"].(string); ok { + if isGitHubActionsExpression(approvalLabelsStr) { + // GitHub Actions expression: store as-is; raw map retains the string for JSON rendering. + config.ApprovalLabelsExpr = approvalLabelsStr + } else { + // Static comma/newline-separated string: parse at compile time. + parsed := parseCommaSeparatedOrNewlineList(approvalLabelsStr) + config.ApprovalLabels = parsed + configMap["approval-labels"] = toAnySlice(parsed) // normalize raw map for JSON rendering + } } return config diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index 5ca3814f74d..fe571c8ff2a 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -304,9 +304,17 @@ type GitHubToolConfig struct { // BlockedUsers is an optional list of GitHub usernames whose content is unconditionally blocked. // Items from these users receive "blocked" integrity (below "none") and are always denied. BlockedUsers []string `yaml:"blocked-users,omitempty"` + // BlockedUsersExpr holds a GitHub Actions expression (e.g. "${{ vars.BLOCKED_USERS }}") that + // resolves at runtime to a comma- or newline-separated list of blocked usernames. + // Set when the blocked-users field is a string expression rather than a literal array. + BlockedUsersExpr string `yaml:"-"` // ApprovalLabels is an optional list of GitHub label names that promote a content item's // effective integrity to "approved" when present. Does not override BlockedUsers. ApprovalLabels []string `yaml:"approval-labels,omitempty"` + // ApprovalLabelsExpr holds a GitHub Actions expression (e.g. "${{ vars.APPROVAL_LABELS }}") that + // 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:"-"` } // PlaywrightToolConfig represents the configuration for the Playwright tool diff --git a/pkg/workflow/tools_validation.go b/pkg/workflow/tools_validation.go index 5f6c74e91bb..1cc30658ac2 100644 --- a/pkg/workflow/tools_validation.go +++ b/pkg/workflow/tools_validation.go @@ -73,8 +73,10 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { // AllowedRepos is populated from either 'allowed-repos' (preferred) or deprecated 'repos' during parsing hasRepos := github.AllowedRepos != nil hasMinIntegrity := github.MinIntegrity != "" - hasBlockedUsers := len(github.BlockedUsers) > 0 - hasApprovalLabels := len(github.ApprovalLabels) > 0 + // blocked-users / approval-labels can be an array (BlockedUsers/ApprovalLabels) or a + // GitHub Actions expression string (BlockedUsersExpr/ApprovalLabelsExpr). + hasBlockedUsers := len(github.BlockedUsers) > 0 || github.BlockedUsersExpr != "" + hasApprovalLabels := len(github.ApprovalLabels) > 0 || github.ApprovalLabelsExpr != "" // blocked-users and approval-labels require a guard policy (min-integrity) if (hasBlockedUsers || hasApprovalLabels) && !hasMinIntegrity { @@ -117,7 +119,7 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { return errors.New("invalid guard policy: 'github.min-integrity' must be one of: 'none', 'unapproved', 'approved', 'merged'. Got: '" + string(github.MinIntegrity) + "'") } - // Validate blocked-users (must be non-empty strings) + // Validate blocked-users (must be non-empty strings; expressions are accepted as-is) for i, user := range github.BlockedUsers { if user == "" { toolsValidationLog.Printf("Empty blocked-users entry at index %d in workflow: %s", i, workflowName) @@ -125,7 +127,7 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { } } - // Validate approval-labels (must be non-empty strings) + // Validate approval-labels (must be non-empty strings; expressions are accepted as-is) for i, label := range github.ApprovalLabels { if label == "" { toolsValidationLog.Printf("Empty approval-labels entry at index %d in workflow: %s", i, workflowName) diff --git a/pkg/workflow/tools_validation_test.go b/pkg/workflow/tools_validation_test.go index a227f5b52d5..8e4b38ac3d3 100644 --- a/pkg/workflow/tools_validation_test.go +++ b/pkg/workflow/tools_validation_test.go @@ -463,6 +463,60 @@ func TestValidateGitHubGuardPolicy(t *testing.T) { shouldError: true, errorMsg: "'github.blocked-users' and 'github.approval-labels' require 'github.min-integrity'", }, + { + name: "blocked-users as GitHub Actions expression is valid", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "unapproved", + "blocked-users": "${{ vars.BLOCKED_USERS }}", + }, + }, + shouldError: false, + }, + { + name: "blocked-users as comma-separated static string is valid", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "unapproved", + "blocked-users": "spam-bot, compromised-user", + }, + }, + shouldError: false, + }, + { + name: "blocked-users as newline-separated static string is valid", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "unapproved", + "blocked-users": "spam-bot\ncompromised-user", + }, + }, + shouldError: false, + }, + { + name: "blocked-users expression without min-integrity fails", + toolsMap: map[string]any{ + "github": map[string]any{ + "blocked-users": "${{ vars.BLOCKED_USERS }}", + }, + }, + shouldError: true, + errorMsg: "'github.blocked-users' and 'github.approval-labels' require 'github.min-integrity'", + }, + { + name: "approval-labels as GitHub Actions expression is valid", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "approved", + "approval-labels": "${{ vars.APPROVAL_LABELS }}", + }, + }, + shouldError: false, + }, } for _, tt := range tests { From 5e0b5618abf675330775e8b278d61aeb404b0edd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 03:08:18 +0000 Subject: [PATCH 3/7] Add GH_AW_GITHUB_BLOCKED_USERS and GH_AW_GITHUB_APPROVAL_LABELS as guard policy fallback env vars Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/3b809a8d-825a-40e7-a843-803bcf7714ed --- pkg/cli/compile_guard_policy_test.go | 15 +++++++++++---- pkg/constants/constants.go | 12 ++++++++++++ pkg/workflow/mcp_github_config.go | 11 +++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/pkg/cli/compile_guard_policy_test.go b/pkg/cli/compile_guard_policy_test.go index b574e74a05e..bfb2b341f39 100644 --- a/pkg/cli/compile_guard_policy_test.go +++ b/pkg/cli/compile_guard_policy_test.go @@ -183,10 +183,17 @@ This workflow uses min-integrity without specifying repos. require.NoError(t, err, "Failed to read compiled lock file") lockFileContent := string(lockFileBytes) - // Check that the guard-policies allow-only block contains both repos=all and min-integrity=approved - // in the correct JSON structure expected by the MCP Gateway. - assert.Contains(t, lockFileContent, `"guard-policies": {`+"\n"+` "allow-only": {`+"\n"+` "min-integrity": "approved",`+"\n"+` "repos": "all"`, - "Compiled lock file must include repos=all and min-integrity=approved in the guard-policies allow-only block") + // Check that the guard-policies allow-only block contains the required fields. + // The MCP Gateway requires repos to be present in the allow-only policy. + assert.Contains(t, lockFileContent, `"guard-policies"`, "Compiled lock file must include guard-policies block") + assert.Contains(t, lockFileContent, `"allow-only"`, "Compiled lock file must include allow-only policy") + assert.Contains(t, lockFileContent, `"min-integrity": "approved"`, "Compiled lock file must include min-integrity=approved") + assert.Contains(t, lockFileContent, `"repos": "all"`, "Compiled lock file must default repos to 'all'") + // Fallback expressions for blocked-users and approval-labels should be injected automatically. + assert.Contains(t, lockFileContent, `"blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}"`, + "Compiled lock file must inject blocked-users fallback expression") + assert.Contains(t, lockFileContent, `"approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}"`, + "Compiled lock file must inject approval-labels fallback expression") } // TestGuardPolicyBlockedUsersApprovalLabelsCompiledOutput verifies that blocked-users and diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index a1bd0db0d08..dd9367ccf5c 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -332,6 +332,18 @@ const ( // EnvVarGitHubToken is the GitHub token for repository access EnvVarGitHubToken = "GH_AW_GITHUB_TOKEN" + + // EnvVarGitHubBlockedUsers is the fallback variable for the tools.github.blocked-users guard policy field. + // When blocked-users is not explicitly set in the workflow frontmatter, this variable is used as + // a comma- or newline-separated list of GitHub usernames to block. Set as an org or repo variable + // to apply a consistent block list across all workflows. + EnvVarGitHubBlockedUsers = "GH_AW_GITHUB_BLOCKED_USERS" + + // EnvVarGitHubApprovalLabels is the fallback variable for the tools.github.approval-labels guard policy field. + // When approval-labels is not explicitly set in the workflow frontmatter, this variable is used as + // a comma- or newline-separated list of GitHub label names that promote content to "approved" integrity. + // Set as an org or repo variable to apply a consistent approval label list across all workflows. + EnvVarGitHubApprovalLabels = "GH_AW_GITHUB_APPROVAL_LABELS" ) // DefaultCodexVersion is the default version of the OpenAI Codex CLI diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index fd40a2c305d..9c2f2457797 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -248,6 +248,9 @@ func getGitHubAllowedTools(githubTool any) []string { // Gateway requires repos to be present in the allow-only policy. // Note: repos-only (without min-integrity) is rejected earlier by validateGitHubGuardPolicy, // so this function will never be called with repos but without min-integrity in practice. +// When blocked-users or approval-labels are not set, org/repo variable fallback expressions +// are injected so that GH_AW_GITHUB_BLOCKED_USERS and GH_AW_GITHUB_APPROVAL_LABELS can be +// configured centrally without editing each workflow. // Returns nil if no guard policies are configured. func getGitHubGuardPolicies(githubTool any) map[string]any { if toolConfig, ok := githubTool.(map[string]any); ok { @@ -273,9 +276,17 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { } if hasBlockedUsers { policy["blocked-users"] = blockedUsers + } else { + // Inject org/repo variable fallback so GH_AW_GITHUB_BLOCKED_USERS can be set + // centrally without editing every workflow. + policy["blocked-users"] = fmt.Sprintf("${{ vars.%s || '' }}", constants.EnvVarGitHubBlockedUsers) } if hasApprovalLabels { policy["approval-labels"] = approvalLabels + } else { + // Inject org/repo variable fallback so GH_AW_GITHUB_APPROVAL_LABELS can be set + // centrally without editing every workflow. + policy["approval-labels"] = fmt.Sprintf("${{ vars.%s || '' }}", constants.EnvVarGitHubApprovalLabels) } return map[string]any{ "allow-only": policy, From 523fcd196390849be2f6f8244c55adb11bc39eaf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 03:18:17 +0000 Subject: [PATCH 4/7] Union frontmatter blocked-users/approval-labels with GH_AW_GITHUB_* org/repo variable fallbacks Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/efadfb81-ec02-40ee-ba09-da459ba5d52c --- .github/workflows/contribution-check.lock.yml | 2 + .../workflows/daily-issues-report.lock.yml | 2 + .../workflows/discussion-task-miner.lock.yml | 2 + .github/workflows/grumpy-reviewer.lock.yml | 2 + .github/workflows/issue-arborist.lock.yml | 2 + .github/workflows/issue-monster.lock.yml | 2 + .github/workflows/issue-triage-agent.lock.yml | 2 + .github/workflows/org-health-report.lock.yml | 2 + .github/workflows/plan.lock.yml | 2 + .github/workflows/pr-triage-agent.lock.yml | 2 + .github/workflows/q.lock.yml | 2 + .github/workflows/refiner.lock.yml | 2 + .github/workflows/scout.lock.yml | 2 + .../workflows/smoke-agent-all-merged.lock.yml | 2 + .../workflows/smoke-agent-all-none.lock.yml | 2 + .../smoke-agent-public-approved.lock.yml | 2 + .../smoke-agent-public-none.lock.yml | 2 + .../smoke-agent-scoped-approved.lock.yml | 2 + .../workflows/stale-repo-identifier.lock.yml | 2 + .../weekly-blog-post-writer.lock.yml | 2 + .../workflows/weekly-issue-summary.lock.yml | 2 + .../weekly-safe-outputs-spec-review.lock.yml | 2 + .github/workflows/workflow-generator.lock.yml | 2 + pkg/cli/compile_guard_policy_test.go | 24 ++++--- pkg/workflow/mcp_github_config.go | 68 +++++++++++++------ 25 files changed, 111 insertions(+), 27 deletions(-) diff --git a/.github/workflows/contribution-check.lock.yml b/.github/workflows/contribution-check.lock.yml index 1a2fe4a30a9..d96aea99fbe 100644 --- a/.github/workflows/contribution-check.lock.yml +++ b/.github/workflows/contribution-check.lock.yml @@ -558,6 +558,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index 632779b58a9..835f6d753c1 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -663,6 +663,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/discussion-task-miner.lock.yml b/.github/workflows/discussion-task-miner.lock.yml index e5fdf9d6fc6..dae8d91d36a 100644 --- a/.github/workflows/discussion-task-miner.lock.yml +++ b/.github/workflows/discussion-task-miner.lock.yml @@ -565,6 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/grumpy-reviewer.lock.yml b/.github/workflows/grumpy-reviewer.lock.yml index 0880ea10028..d48d4b7a2cf 100644 --- a/.github/workflows/grumpy-reviewer.lock.yml +++ b/.github/workflows/grumpy-reviewer.lock.yml @@ -648,6 +648,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/issue-arborist.lock.yml b/.github/workflows/issue-arborist.lock.yml index 7e642a67ea9..2067e65dc16 100644 --- a/.github/workflows/issue-arborist.lock.yml +++ b/.github/workflows/issue-arborist.lock.yml @@ -608,6 +608,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 7fe766aaa1e..059e195f6ee 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -912,6 +912,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/issue-triage-agent.lock.yml b/.github/workflows/issue-triage-agent.lock.yml index 6eb15ad7fbe..a59c25a2c24 100644 --- a/.github/workflows/issue-triage-agent.lock.yml +++ b/.github/workflows/issue-triage-agent.lock.yml @@ -507,6 +507,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/org-health-report.lock.yml b/.github/workflows/org-health-report.lock.yml index 89b3c32ebcf..17a2420580e 100644 --- a/.github/workflows/org-health-report.lock.yml +++ b/.github/workflows/org-health-report.lock.yml @@ -579,6 +579,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/plan.lock.yml b/.github/workflows/plan.lock.yml index 201f3515609..caa1fa637d3 100644 --- a/.github/workflows/plan.lock.yml +++ b/.github/workflows/plan.lock.yml @@ -604,6 +604,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/pr-triage-agent.lock.yml b/.github/workflows/pr-triage-agent.lock.yml index f6fa5111ff5..0e7508e5e08 100644 --- a/.github/workflows/pr-triage-agent.lock.yml +++ b/.github/workflows/pr-triage-agent.lock.yml @@ -576,6 +576,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index 01ce7f57d62..c411b110188 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -750,6 +750,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/refiner.lock.yml b/.github/workflows/refiner.lock.yml index cb8a1743b61..fae065bb012 100644 --- a/.github/workflows/refiner.lock.yml +++ b/.github/workflows/refiner.lock.yml @@ -568,6 +568,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index 20398bf38a0..cd68ef74dcb 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -711,6 +711,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/smoke-agent-all-merged.lock.yml b/.github/workflows/smoke-agent-all-merged.lock.yml index 15882267628..9065e52cf17 100644 --- a/.github/workflows/smoke-agent-all-merged.lock.yml +++ b/.github/workflows/smoke-agent-all-merged.lock.yml @@ -565,6 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "merged", "repos": "all" } diff --git a/.github/workflows/smoke-agent-all-none.lock.yml b/.github/workflows/smoke-agent-all-none.lock.yml index bb7f43e231d..144e2c06951 100644 --- a/.github/workflows/smoke-agent-all-none.lock.yml +++ b/.github/workflows/smoke-agent-all-none.lock.yml @@ -565,6 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/smoke-agent-public-approved.lock.yml b/.github/workflows/smoke-agent-public-approved.lock.yml index a20963be3cb..ea250ee5227 100644 --- a/.github/workflows/smoke-agent-public-approved.lock.yml +++ b/.github/workflows/smoke-agent-public-approved.lock.yml @@ -591,6 +591,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "public" } diff --git a/.github/workflows/smoke-agent-public-none.lock.yml b/.github/workflows/smoke-agent-public-none.lock.yml index e01dd61a1b7..ebe35e6e42c 100644 --- a/.github/workflows/smoke-agent-public-none.lock.yml +++ b/.github/workflows/smoke-agent-public-none.lock.yml @@ -565,6 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "none", "repos": "public" } diff --git a/.github/workflows/smoke-agent-scoped-approved.lock.yml b/.github/workflows/smoke-agent-scoped-approved.lock.yml index bb2f172d2dd..046048aa0a4 100644 --- a/.github/workflows/smoke-agent-scoped-approved.lock.yml +++ b/.github/workflows/smoke-agent-scoped-approved.lock.yml @@ -565,6 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": [ "github/gh-aw", diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 0f241d7aecf..6ede5b6e6b3 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -637,6 +637,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/weekly-blog-post-writer.lock.yml b/.github/workflows/weekly-blog-post-writer.lock.yml index fd9b0db05f2..bf2aae1fd61 100644 --- a/.github/workflows/weekly-blog-post-writer.lock.yml +++ b/.github/workflows/weekly-blog-post-writer.lock.yml @@ -748,6 +748,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": [ "github/gh-aw" diff --git a/.github/workflows/weekly-issue-summary.lock.yml b/.github/workflows/weekly-issue-summary.lock.yml index 35f5b565e21..269228317e9 100644 --- a/.github/workflows/weekly-issue-summary.lock.yml +++ b/.github/workflows/weekly-issue-summary.lock.yml @@ -559,6 +559,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/weekly-safe-outputs-spec-review.lock.yml b/.github/workflows/weekly-safe-outputs-spec-review.lock.yml index 3d4a9ddc2e4..a3387217380 100644 --- a/.github/workflows/weekly-safe-outputs-spec-review.lock.yml +++ b/.github/workflows/weekly-safe-outputs-spec-review.lock.yml @@ -517,6 +517,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/workflow-generator.lock.yml b/.github/workflows/workflow-generator.lock.yml index aebfbf0a1e2..f7807b58f91 100644 --- a/.github/workflows/workflow-generator.lock.yml +++ b/.github/workflows/workflow-generator.lock.yml @@ -600,6 +600,8 @@ jobs: }, "guard-policies": { "allow-only": { + "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", + "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", "min-integrity": "approved", "repos": "all" } diff --git a/pkg/cli/compile_guard_policy_test.go b/pkg/cli/compile_guard_policy_test.go index bfb2b341f39..86969b0380e 100644 --- a/pkg/cli/compile_guard_policy_test.go +++ b/pkg/cli/compile_guard_policy_test.go @@ -237,12 +237,16 @@ This workflow uses blocked-users and approval-labels. require.NoError(t, err, "Failed to read compiled lock file") lockFileContent := string(lockFileBytes) + // With union semantics, blocked-users and approval-labels are rendered as a newline-separated + // string that includes both the explicit values and the GH_AW_GITHUB_* fallback expression. assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users in the guard-policies allow-only block") - assert.Contains(t, lockFileContent, `"spam-bot"`, "Compiled lock file must include spam-bot in blocked-users") - assert.Contains(t, lockFileContent, `"compromised-user"`, "Compiled lock file must include compromised-user in blocked-users") + assert.Contains(t, lockFileContent, `spam-bot`, "Compiled lock file must include spam-bot in blocked-users") + assert.Contains(t, lockFileContent, `compromised-user`, "Compiled lock file must include compromised-user in blocked-users") + assert.Contains(t, lockFileContent, `GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must include blocked-users fallback expression") assert.Contains(t, lockFileContent, `"approval-labels"`, "Compiled lock file must include approval-labels in the guard-policies allow-only block") - assert.Contains(t, lockFileContent, `"human-reviewed"`, "Compiled lock file must include human-reviewed in approval-labels") - assert.Contains(t, lockFileContent, `"safe-for-agent"`, "Compiled lock file must include safe-for-agent in approval-labels") + assert.Contains(t, lockFileContent, `human-reviewed`, "Compiled lock file must include human-reviewed in approval-labels") + assert.Contains(t, lockFileContent, `safe-for-agent`, "Compiled lock file must include safe-for-agent in approval-labels") + assert.Contains(t, lockFileContent, `GH_AW_GITHUB_APPROVAL_LABELS`, "Compiled lock file must include approval-labels fallback expression") } // TestGuardPolicyBlockedUsersExpressionCompiledOutput verifies that blocked-users as a GitHub @@ -281,11 +285,13 @@ This workflow passes blocked-users and approval-labels as expressions. require.NoError(t, err, "Failed to read compiled lock file") lockFileContent := string(lockFileBytes) - // Expressions should be passed through as string values in the JSON config. + // Expressions should be unioned with the GH_AW_GITHUB_* fallback in a newline-separated string. assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users") assert.Contains(t, lockFileContent, `vars.BLOCKED_USERS`, "Compiled lock file must preserve the blocked-users expression") + assert.Contains(t, lockFileContent, `GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must union blocked-users with fallback expression") assert.Contains(t, lockFileContent, `"approval-labels"`, "Compiled lock file must include approval-labels") assert.Contains(t, lockFileContent, `vars.APPROVAL_LABELS`, "Compiled lock file must preserve the approval-labels expression") + assert.Contains(t, lockFileContent, `GH_AW_GITHUB_APPROVAL_LABELS`, "Compiled lock file must union approval-labels with fallback expression") } // TestGuardPolicyBlockedUsersCommaSeparatedCompiledOutput verifies that a static @@ -323,8 +329,10 @@ This workflow passes blocked-users as a comma-separated string. require.NoError(t, err, "Failed to read compiled lock file") lockFileContent := string(lockFileBytes) - // Static comma-separated strings should be split into a JSON array at compile time. + // With union semantics, static comma-separated strings are split and then combined with + // the GH_AW_GITHUB_BLOCKED_USERS fallback expression into a newline-separated string. assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users") - assert.Contains(t, lockFileContent, `"spam-bot"`, "Compiled lock file must split spam-bot from comma-separated string") - assert.Contains(t, lockFileContent, `"compromised-user"`, "Compiled lock file must split compromised-user from comma-separated string") + assert.Contains(t, lockFileContent, `spam-bot`, "Compiled lock file must split spam-bot from comma-separated string") + assert.Contains(t, lockFileContent, `compromised-user`, "Compiled lock file must split compromised-user from comma-separated string") + assert.Contains(t, lockFileContent, `GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must union with blocked-users fallback expression") } diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 9c2f2457797..29c42526dba 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -248,9 +248,9 @@ func getGitHubAllowedTools(githubTool any) []string { // Gateway requires repos to be present in the allow-only policy. // Note: repos-only (without min-integrity) is rejected earlier by validateGitHubGuardPolicy, // so this function will never be called with repos but without min-integrity in practice. -// When blocked-users or approval-labels are not set, org/repo variable fallback expressions -// are injected so that GH_AW_GITHUB_BLOCKED_USERS and GH_AW_GITHUB_APPROVAL_LABELS can be -// configured centrally without editing each workflow. +// When blocked-users or approval-labels are set, their values are unioned with the org/repo +// variable fallback expressions (GH_AW_GITHUB_BLOCKED_USERS / GH_AW_GITHUB_APPROVAL_LABELS) +// so that a centrally-configured variable extends the per-workflow list rather than replacing it. // Returns nil if no guard policies are configured. func getGitHubGuardPolicies(githubTool any) map[string]any { if toolConfig, ok := githubTool.(map[string]any); ok { @@ -260,8 +260,8 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { repos, hasRepos = toolConfig["repos"] } integrity, hasIntegrity := toolConfig["min-integrity"] - blockedUsers, hasBlockedUsers := toolConfig["blocked-users"] - approvalLabels, hasApprovalLabels := toolConfig["approval-labels"] + blockedUsers := toolConfig["blocked-users"] + approvalLabels := toolConfig["approval-labels"] if hasRepos || hasIntegrity { policy := map[string]any{} if hasRepos { @@ -274,20 +274,11 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { if hasIntegrity { policy["min-integrity"] = integrity } - if hasBlockedUsers { - policy["blocked-users"] = blockedUsers - } else { - // Inject org/repo variable fallback so GH_AW_GITHUB_BLOCKED_USERS can be set - // centrally without editing every workflow. - policy["blocked-users"] = fmt.Sprintf("${{ vars.%s || '' }}", constants.EnvVarGitHubBlockedUsers) - } - if hasApprovalLabels { - policy["approval-labels"] = approvalLabels - } else { - // Inject org/repo variable fallback so GH_AW_GITHUB_APPROVAL_LABELS can be set - // centrally without editing every workflow. - policy["approval-labels"] = fmt.Sprintf("${{ vars.%s || '' }}", constants.EnvVarGitHubApprovalLabels) - } + // Always union blocked-users with the GH_AW_GITHUB_BLOCKED_USERS org/repo variable so + // that a centrally-configured variable extends the per-workflow list. + policy["blocked-users"] = unionWithEnvFallback(blockedUsers, constants.EnvVarGitHubBlockedUsers) + // Always union approval-labels with the GH_AW_GITHUB_APPROVAL_LABELS org/repo variable. + policy["approval-labels"] = unionWithEnvFallback(approvalLabels, constants.EnvVarGitHubApprovalLabels) return map[string]any{ "allow-only": policy, } @@ -296,6 +287,45 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { return nil } +// unionWithEnvFallback builds a newline-separated string that combines any explicitly +// configured blocked-users / approval-labels values with an org/repo variable fallback. +// The MCP Gateway splits the string on newlines (and commas) at runtime and ignores empty +// entries, so the fallback being an empty string when the variable is unset is harmless. +// +// - nil / not set → just the fallback expression +// - []any or []string → each element joined by "\n", then the fallback appended +// - string expression → the expression string, then the fallback appended +func unionWithEnvFallback(existing any, envVar string) string { + fallback := fmt.Sprintf("${{ vars.%s || '' }}", envVar) + switch v := existing.(type) { + case []any: + parts := make([]string, 0, len(v)+1) + for _, item := range v { + if str, ok := item.(string); ok && str != "" { + parts = append(parts, str) + } + } + parts = append(parts, fallback) + return strings.Join(parts, "\n") + case []string: + parts := make([]string, 0, len(v)+1) + for _, s := range v { + if s != "" { + parts = append(parts, s) + } + } + parts = append(parts, fallback) + return strings.Join(parts, "\n") + case string: + if v != "" { + return v + "\n" + fallback + } + return fallback + default: + return fallback + } +} + // 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: From 6a8a28c212f24bfe2a38b2ea2656af977c3a3ec1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 03:48:38 +0000 Subject: [PATCH 5/7] Use toJSON() via sentinel+post-processing for proper JSON encoding of guard policy expressions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e99804f1-92b5-448a-b715-8bbd4388eb13 --- .github/workflows/contribution-check.lock.yml | 4 +- .../workflows/daily-issues-report.lock.yml | 4 +- .../workflows/discussion-task-miner.lock.yml | 4 +- .github/workflows/grumpy-reviewer.lock.yml | 4 +- .github/workflows/issue-arborist.lock.yml | 4 +- .github/workflows/issue-monster.lock.yml | 4 +- .github/workflows/issue-triage-agent.lock.yml | 4 +- .github/workflows/org-health-report.lock.yml | 4 +- .github/workflows/plan.lock.yml | 4 +- .github/workflows/pr-triage-agent.lock.yml | 4 +- .github/workflows/q.lock.yml | 4 +- .github/workflows/refiner.lock.yml | 4 +- .github/workflows/scout.lock.yml | 4 +- .../workflows/smoke-agent-all-merged.lock.yml | 4 +- .../workflows/smoke-agent-all-none.lock.yml | 4 +- .../smoke-agent-public-approved.lock.yml | 4 +- .../smoke-agent-public-none.lock.yml | 4 +- .../smoke-agent-scoped-approved.lock.yml | 4 +- .../workflows/stale-repo-identifier.lock.yml | 4 +- .../weekly-blog-post-writer.lock.yml | 4 +- .../workflows/weekly-issue-summary.lock.yml | 4 +- .../weekly-safe-outputs-spec-review.lock.yml | 4 +- .github/workflows/workflow-generator.lock.yml | 4 +- pkg/cli/compile_guard_policy_test.go | 30 +++++++------- pkg/workflow/mcp_github_config.go | 39 ++++++++++++------- pkg/workflow/mcp_renderer_guard.go | 34 +++++++++++++++- 26 files changed, 120 insertions(+), 75 deletions(-) diff --git a/.github/workflows/contribution-check.lock.yml b/.github/workflows/contribution-check.lock.yml index d96aea99fbe..6847e4733cf 100644 --- a/.github/workflows/contribution-check.lock.yml +++ b/.github/workflows/contribution-check.lock.yml @@ -558,8 +558,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index 835f6d753c1..a0460c34fa7 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -663,8 +663,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/discussion-task-miner.lock.yml b/.github/workflows/discussion-task-miner.lock.yml index dae8d91d36a..1e89c7dbb57 100644 --- a/.github/workflows/discussion-task-miner.lock.yml +++ b/.github/workflows/discussion-task-miner.lock.yml @@ -565,8 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/grumpy-reviewer.lock.yml b/.github/workflows/grumpy-reviewer.lock.yml index d48d4b7a2cf..7c622ce7fab 100644 --- a/.github/workflows/grumpy-reviewer.lock.yml +++ b/.github/workflows/grumpy-reviewer.lock.yml @@ -648,8 +648,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/issue-arborist.lock.yml b/.github/workflows/issue-arborist.lock.yml index 2067e65dc16..3888dc14f9d 100644 --- a/.github/workflows/issue-arborist.lock.yml +++ b/.github/workflows/issue-arborist.lock.yml @@ -608,8 +608,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 059e195f6ee..019c3ea65c0 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -912,8 +912,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/issue-triage-agent.lock.yml b/.github/workflows/issue-triage-agent.lock.yml index a59c25a2c24..afe6cb5f610 100644 --- a/.github/workflows/issue-triage-agent.lock.yml +++ b/.github/workflows/issue-triage-agent.lock.yml @@ -507,8 +507,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/org-health-report.lock.yml b/.github/workflows/org-health-report.lock.yml index 17a2420580e..cc1274c78b5 100644 --- a/.github/workflows/org-health-report.lock.yml +++ b/.github/workflows/org-health-report.lock.yml @@ -579,8 +579,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/plan.lock.yml b/.github/workflows/plan.lock.yml index caa1fa637d3..77699774fd5 100644 --- a/.github/workflows/plan.lock.yml +++ b/.github/workflows/plan.lock.yml @@ -604,8 +604,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/pr-triage-agent.lock.yml b/.github/workflows/pr-triage-agent.lock.yml index 0e7508e5e08..5a0832aa8c9 100644 --- a/.github/workflows/pr-triage-agent.lock.yml +++ b/.github/workflows/pr-triage-agent.lock.yml @@ -576,8 +576,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index c411b110188..0c16223ca75 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -750,8 +750,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/refiner.lock.yml b/.github/workflows/refiner.lock.yml index fae065bb012..df70b16840c 100644 --- a/.github/workflows/refiner.lock.yml +++ b/.github/workflows/refiner.lock.yml @@ -568,8 +568,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index cd68ef74dcb..3340a9d0222 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -711,8 +711,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/smoke-agent-all-merged.lock.yml b/.github/workflows/smoke-agent-all-merged.lock.yml index 9065e52cf17..265921efe5c 100644 --- a/.github/workflows/smoke-agent-all-merged.lock.yml +++ b/.github/workflows/smoke-agent-all-merged.lock.yml @@ -565,8 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "merged", "repos": "all" } diff --git a/.github/workflows/smoke-agent-all-none.lock.yml b/.github/workflows/smoke-agent-all-none.lock.yml index 144e2c06951..c359ba2981b 100644 --- a/.github/workflows/smoke-agent-all-none.lock.yml +++ b/.github/workflows/smoke-agent-all-none.lock.yml @@ -565,8 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/smoke-agent-public-approved.lock.yml b/.github/workflows/smoke-agent-public-approved.lock.yml index ea250ee5227..c7e920a336c 100644 --- a/.github/workflows/smoke-agent-public-approved.lock.yml +++ b/.github/workflows/smoke-agent-public-approved.lock.yml @@ -591,8 +591,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "public" } diff --git a/.github/workflows/smoke-agent-public-none.lock.yml b/.github/workflows/smoke-agent-public-none.lock.yml index ebe35e6e42c..d0387021e5c 100644 --- a/.github/workflows/smoke-agent-public-none.lock.yml +++ b/.github/workflows/smoke-agent-public-none.lock.yml @@ -565,8 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "none", "repos": "public" } diff --git a/.github/workflows/smoke-agent-scoped-approved.lock.yml b/.github/workflows/smoke-agent-scoped-approved.lock.yml index 046048aa0a4..a8a43b40815 100644 --- a/.github/workflows/smoke-agent-scoped-approved.lock.yml +++ b/.github/workflows/smoke-agent-scoped-approved.lock.yml @@ -565,8 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": [ "github/gh-aw", diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 6ede5b6e6b3..64352136425 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -637,8 +637,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/weekly-blog-post-writer.lock.yml b/.github/workflows/weekly-blog-post-writer.lock.yml index bf2aae1fd61..93461eb84de 100644 --- a/.github/workflows/weekly-blog-post-writer.lock.yml +++ b/.github/workflows/weekly-blog-post-writer.lock.yml @@ -748,8 +748,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": [ "github/gh-aw" diff --git a/.github/workflows/weekly-issue-summary.lock.yml b/.github/workflows/weekly-issue-summary.lock.yml index 269228317e9..51602eceeff 100644 --- a/.github/workflows/weekly-issue-summary.lock.yml +++ b/.github/workflows/weekly-issue-summary.lock.yml @@ -559,8 +559,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/weekly-safe-outputs-spec-review.lock.yml b/.github/workflows/weekly-safe-outputs-spec-review.lock.yml index a3387217380..f03ce960760 100644 --- a/.github/workflows/weekly-safe-outputs-spec-review.lock.yml +++ b/.github/workflows/weekly-safe-outputs-spec-review.lock.yml @@ -517,8 +517,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/workflow-generator.lock.yml b/.github/workflows/workflow-generator.lock.yml index f7807b58f91..9a58125d578 100644 --- a/.github/workflows/workflow-generator.lock.yml +++ b/.github/workflows/workflow-generator.lock.yml @@ -600,8 +600,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}", - "blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}", + "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, + "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, "min-integrity": "approved", "repos": "all" } diff --git a/pkg/cli/compile_guard_policy_test.go b/pkg/cli/compile_guard_policy_test.go index 86969b0380e..fa0c16ca8a2 100644 --- a/pkg/cli/compile_guard_policy_test.go +++ b/pkg/cli/compile_guard_policy_test.go @@ -189,11 +189,12 @@ This workflow uses min-integrity without specifying repos. assert.Contains(t, lockFileContent, `"allow-only"`, "Compiled lock file must include allow-only policy") assert.Contains(t, lockFileContent, `"min-integrity": "approved"`, "Compiled lock file must include min-integrity=approved") assert.Contains(t, lockFileContent, `"repos": "all"`, "Compiled lock file must default repos to 'all'") - // Fallback expressions for blocked-users and approval-labels should be injected automatically. - assert.Contains(t, lockFileContent, `"blocked-users": "${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}"`, - "Compiled lock file must inject blocked-users fallback expression") - assert.Contains(t, lockFileContent, `"approval-labels": "${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}"`, - "Compiled lock file must inject approval-labels fallback expression") + // Fallback expressions for blocked-users and approval-labels should be injected automatically + // using toJSON() to ensure proper JSON encoding at runtime. + assert.Contains(t, lockFileContent, `"blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}`, + "Compiled lock file must inject blocked-users toJSON fallback expression") + assert.Contains(t, lockFileContent, `"approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}`, + "Compiled lock file must inject approval-labels toJSON fallback expression") } // TestGuardPolicyBlockedUsersApprovalLabelsCompiledOutput verifies that blocked-users and @@ -237,16 +238,16 @@ This workflow uses blocked-users and approval-labels. require.NoError(t, err, "Failed to read compiled lock file") lockFileContent := string(lockFileBytes) - // With union semantics, blocked-users and approval-labels are rendered as a newline-separated - // string that includes both the explicit values and the GH_AW_GITHUB_* fallback expression. + // With union semantics, blocked-users and approval-labels are rendered as a JSON array + // containing the explicit values and a toJSON() fallback expression as the last element. assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users in the guard-policies allow-only block") assert.Contains(t, lockFileContent, `spam-bot`, "Compiled lock file must include spam-bot in blocked-users") assert.Contains(t, lockFileContent, `compromised-user`, "Compiled lock file must include compromised-user in blocked-users") - assert.Contains(t, lockFileContent, `GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must include blocked-users fallback expression") + assert.Contains(t, lockFileContent, `toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must include blocked-users toJSON fallback") assert.Contains(t, lockFileContent, `"approval-labels"`, "Compiled lock file must include approval-labels in the guard-policies allow-only block") assert.Contains(t, lockFileContent, `human-reviewed`, "Compiled lock file must include human-reviewed in approval-labels") assert.Contains(t, lockFileContent, `safe-for-agent`, "Compiled lock file must include safe-for-agent in approval-labels") - assert.Contains(t, lockFileContent, `GH_AW_GITHUB_APPROVAL_LABELS`, "Compiled lock file must include approval-labels fallback expression") + assert.Contains(t, lockFileContent, `toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS`, "Compiled lock file must include approval-labels toJSON fallback") } // TestGuardPolicyBlockedUsersExpressionCompiledOutput verifies that blocked-users as a GitHub @@ -285,13 +286,14 @@ This workflow passes blocked-users and approval-labels as expressions. require.NoError(t, err, "Failed to read compiled lock file") lockFileContent := string(lockFileBytes) - // Expressions should be unioned with the GH_AW_GITHUB_* fallback in a newline-separated string. + // Expressions should be unioned with the GH_AW_GITHUB_* fallback in an array. + // The fallback uses toJSON() for proper JSON encoding. assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users") assert.Contains(t, lockFileContent, `vars.BLOCKED_USERS`, "Compiled lock file must preserve the blocked-users expression") - assert.Contains(t, lockFileContent, `GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must union blocked-users with fallback expression") + assert.Contains(t, lockFileContent, `toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must union blocked-users with toJSON fallback") assert.Contains(t, lockFileContent, `"approval-labels"`, "Compiled lock file must include approval-labels") assert.Contains(t, lockFileContent, `vars.APPROVAL_LABELS`, "Compiled lock file must preserve the approval-labels expression") - assert.Contains(t, lockFileContent, `GH_AW_GITHUB_APPROVAL_LABELS`, "Compiled lock file must union approval-labels with fallback expression") + assert.Contains(t, lockFileContent, `toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS`, "Compiled lock file must union approval-labels with toJSON fallback") } // TestGuardPolicyBlockedUsersCommaSeparatedCompiledOutput verifies that a static @@ -330,9 +332,9 @@ This workflow passes blocked-users as a comma-separated string. lockFileContent := string(lockFileBytes) // With union semantics, static comma-separated strings are split and then combined with - // the GH_AW_GITHUB_BLOCKED_USERS fallback expression into a newline-separated string. + // the GH_AW_GITHUB_BLOCKED_USERS toJSON fallback into a JSON array. assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users") assert.Contains(t, lockFileContent, `spam-bot`, "Compiled lock file must split spam-bot from comma-separated string") assert.Contains(t, lockFileContent, `compromised-user`, "Compiled lock file must split compromised-user from comma-separated string") - assert.Contains(t, lockFileContent, `GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must union with blocked-users fallback expression") + assert.Contains(t, lockFileContent, `toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must union with blocked-users toJSON fallback") } diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 29c42526dba..89f0e08c83b 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -287,41 +287,52 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { return nil } -// unionWithEnvFallback builds a newline-separated string that combines any explicitly -// configured blocked-users / approval-labels values with an org/repo variable fallback. -// The MCP Gateway splits the string on newlines (and commas) at runtime and ignores empty -// entries, so the fallback being an empty string when the variable is unset is harmless. +// unionWithEnvFallback builds a JSON-safe value that unions any explicitly configured +// blocked-users / approval-labels values with an org/repo variable fallback. // -// - nil / not set → just the fallback expression -// - []any or []string → each element joined by "\n", then the fallback appended -// - string expression → the expression string, then the fallback appended -func unionWithEnvFallback(existing any, envVar string) string { - fallback := fmt.Sprintf("${{ vars.%s || '' }}", envVar) +// For the fallback expression, the returned string is prefixed with guardExprSentinel so +// that renderGuardPoliciesJSON can strip the surrounding JSON string quotes and emit the +// toJSON() expression verbatim. At runtime, GitHub Actions evaluates toJSON() which properly +// JSON-encodes the variable value, preventing JSON injection even if the value contains +// double quotes or backslashes. +// +// - nil / not set → sentinel-prefixed toJSON fallback expression (rendered without quotes) +// - []any or []string → JSON array of static string values with the sentinel fallback as last element +// - string expression → JSON array of [expression, sentinel fallback] +func unionWithEnvFallback(existing any, envVar string) any { + // The sentinel prefix tells renderGuardPoliciesJSON to un-quote this value and emit the + // toJSON() expression verbatim. toJSON() ensures the variable value is properly + // JSON-encoded at runtime. + fallback := guardExprSentinel + fmt.Sprintf(`${{ toJSON(vars.%s || '') }}`, envVar) + switch v := existing.(type) { case []any: - parts := make([]string, 0, len(v)+1) + parts := make([]any, 0, len(v)+1) for _, item := range v { if str, ok := item.(string); ok && str != "" { parts = append(parts, str) } } parts = append(parts, fallback) - return strings.Join(parts, "\n") + return parts case []string: - parts := make([]string, 0, len(v)+1) + parts := make([]any, 0, len(v)+1) for _, s := range v { if s != "" { parts = append(parts, s) } } parts = append(parts, fallback) - return strings.Join(parts, "\n") + return parts case string: if v != "" { - return v + "\n" + fallback + // User-provided expression (e.g. "${{ vars.CUSTOM }}") unioned with the fallback. + return []any{v, fallback} } + // Fallback only: sentinel-prefixed toJSON expression (no surrounding string quotes). return fallback default: + // Fallback only. return fallback } } diff --git a/pkg/workflow/mcp_renderer_guard.go b/pkg/workflow/mcp_renderer_guard.go index 6e535012152..a1338d21ee1 100644 --- a/pkg/workflow/mcp_renderer_guard.go +++ b/pkg/workflow/mcp_renderer_guard.go @@ -3,12 +3,37 @@ package workflow import ( "encoding/json" "fmt" + "regexp" "strings" ) +// guardExprSentinel is a prefix that marks a string value in the guard-policies map as a +// raw GitHub Actions expression that should be emitted verbatim (without surrounding JSON +// string quotes) in the final output. +// +// Background: json.MarshalIndent cannot emit non-JSON content verbatim (it validates +// json.RawMessage content), so we use a sentinel string that json.MarshalIndent can safely +// encode as part of a regular JSON string, then post-process the output to un-quote those +// values. Paired with toJSON() in the expression, this ensures the variable value is +// properly JSON-encoded at runtime even if it contains double quotes or backslashes. +const guardExprSentinel = "__GH_AW_GUARD_EXPR:" + +// guardExprRE matches sentinel-prefixed expression values in the JSON output: +// +// "__GH_AW_GUARD_EXPR:${{ expr }}" → ${{ expr }} +// +// Expressions are always of the form ${{ ... }} and must not contain double quotes +// (our generated expressions use single-quoted strings inside the GitHub Actions expression, +// so this invariant holds for all compiler-generated fallback values). +var guardExprRE = regexp.MustCompile(`"` + regexp.QuoteMeta(guardExprSentinel) + `(\$\{\{[^"]+\}\})"`) + // renderGuardPoliciesJSON renders a "guard-policies" JSON field at the given indent level. // The policies map contains policy names (e.g., "allow-only") mapped to their configurations. // Renders as the last field (no trailing comma) with the given base indent. +// +// Any string value that starts with guardExprSentinel is treated as a raw GitHub Actions +// expression. After json.MarshalIndent, those sentinel-prefixed strings are replaced with +// the un-quoted expression so that toJSON() can properly encode the value at runtime. func renderGuardPoliciesJSON(yaml *strings.Builder, policies map[string]any, indent string) { if len(policies) == 0 { return @@ -21,7 +46,14 @@ func renderGuardPoliciesJSON(yaml *strings.Builder, policies map[string]any, ind return } - fmt.Fprintf(yaml, "%s\"guard-policies\": %s\n", indent, string(jsonBytes)) + // Un-quote sentinel-prefixed expression values so they are emitted as raw GitHub Actions + // expressions. For example: + // Before: "blocked-users": "__GH_AW_GUARD_EXPR:${{ toJSON(vars.X || '') }}" + // After: "blocked-users": ${{ toJSON(vars.X || '') }} + // At runtime, GitHub Actions evaluates toJSON() which properly JSON-encodes the value. + output := guardExprRE.ReplaceAllString(string(jsonBytes), `$1`) + + fmt.Fprintf(yaml, "%s\"guard-policies\": %s\n", indent, output) } // renderGuardPoliciesToml renders a "guard-policies" section in TOML format for a given server. From bbf8f9e2d4a1ed3e50837fdccba9414d7d7ef177 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 04:07:35 +0000 Subject: [PATCH 6/7] Add changeset [skip-ci] --- .../patch-add-github-guard-blocked-users-approval-labels.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/patch-add-github-guard-blocked-users-approval-labels.md diff --git a/.changeset/patch-add-github-guard-blocked-users-approval-labels.md b/.changeset/patch-add-github-guard-blocked-users-approval-labels.md new file mode 100644 index 00000000000..fcbec80e777 --- /dev/null +++ b/.changeset/patch-add-github-guard-blocked-users-approval-labels.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add `blocked-users` and `approval-labels` support to `tools.github` guard policies, including schema/parser/validation updates and safe runtime fallback merging with `GH_AW_GITHUB_BLOCKED_USERS` and `GH_AW_GITHUB_APPROVAL_LABELS` via `toJSON()` rendering. From fd7578bb7d512e160eeb76a58519e655aabf62af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 04:19:16 +0000 Subject: [PATCH 7/7] Add parse_guard_list.sh step to parse blocked-users/approval-labels variables as JSON arrays at runtime Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7a6d9c5d-3431-4860-8b12-0707199d67f2 --- ...hub-guard-blocked-users-approval-labels.md | 2 +- .github/workflows/contribution-check.lock.yml | 10 ++- .../workflows/daily-issues-report.lock.yml | 10 ++- .../workflows/discussion-task-miner.lock.yml | 10 ++- .github/workflows/grumpy-reviewer.lock.yml | 10 ++- .github/workflows/issue-arborist.lock.yml | 10 ++- .github/workflows/issue-monster.lock.yml | 10 ++- .github/workflows/issue-triage-agent.lock.yml | 10 ++- .github/workflows/org-health-report.lock.yml | 10 ++- .github/workflows/plan.lock.yml | 10 ++- .github/workflows/pr-triage-agent.lock.yml | 10 ++- .github/workflows/q.lock.yml | 10 ++- .github/workflows/refiner.lock.yml | 10 ++- .github/workflows/scout.lock.yml | 10 ++- .../workflows/smoke-agent-all-merged.lock.yml | 10 ++- .../workflows/smoke-agent-all-none.lock.yml | 10 ++- .../smoke-agent-public-approved.lock.yml | 10 ++- .../smoke-agent-public-none.lock.yml | 10 ++- .../smoke-agent-scoped-approved.lock.yml | 10 ++- .../workflows/stale-repo-identifier.lock.yml | 10 ++- .../weekly-blog-post-writer.lock.yml | 10 ++- .../workflows/weekly-issue-summary.lock.yml | 10 ++- .../weekly-safe-outputs-spec-review.lock.yml | 10 ++- .github/workflows/workflow-generator.lock.yml | 10 ++- actions/setup/sh/parse_guard_list.sh | 84 +++++++++++++++++++ pkg/cli/compile_guard_policy_test.go | 57 +++++++------ pkg/workflow/compiler_github_mcp_steps.go | 67 +++++++++++++++ pkg/workflow/compiler_yaml_main_job.go | 3 + pkg/workflow/mcp_github_config.go | 62 ++------------ 29 files changed, 376 insertions(+), 129 deletions(-) create mode 100755 actions/setup/sh/parse_guard_list.sh diff --git a/.changeset/patch-add-github-guard-blocked-users-approval-labels.md b/.changeset/patch-add-github-guard-blocked-users-approval-labels.md index fcbec80e777..2ad241fad47 100644 --- a/.changeset/patch-add-github-guard-blocked-users-approval-labels.md +++ b/.changeset/patch-add-github-guard-blocked-users-approval-labels.md @@ -2,4 +2,4 @@ "gh-aw": patch --- -Add `blocked-users` and `approval-labels` support to `tools.github` guard policies, including schema/parser/validation updates and safe runtime fallback merging with `GH_AW_GITHUB_BLOCKED_USERS` and `GH_AW_GITHUB_APPROVAL_LABELS` via `toJSON()` rendering. +Add `blocked-users` and `approval-labels` support to `tools.github` guard policies, including schema/parser/validation updates and runtime parsing via `parse_guard_list.sh` — which merges compile-time static values with `GH_AW_GITHUB_BLOCKED_USERS` and `GH_AW_GITHUB_APPROVAL_LABELS` org/repo variables into proper JSON arrays (split on comma/newline, validated, jq-encoded). diff --git a/.github/workflows/contribution-check.lock.yml b/.github/workflows/contribution-check.lock.yml index 6847e4733cf..19fa2819717 100644 --- a/.github/workflows/contribution-check.lock.yml +++ b/.github/workflows/contribution-check.lock.yml @@ -325,6 +325,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -558,8 +564,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index a0460c34fa7..d2a57f4e5af 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -407,6 +407,12 @@ jobs: run: npm install -g @openai/codex@latest - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -663,8 +669,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/discussion-task-miner.lock.yml b/.github/workflows/discussion-task-miner.lock.yml index 1e89c7dbb57..474e0897721 100644 --- a/.github/workflows/discussion-task-miner.lock.yml +++ b/.github/workflows/discussion-task-miner.lock.yml @@ -352,6 +352,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -565,8 +571,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/grumpy-reviewer.lock.yml b/.github/workflows/grumpy-reviewer.lock.yml index 7c622ce7fab..d5cc855c2d7 100644 --- a/.github/workflows/grumpy-reviewer.lock.yml +++ b/.github/workflows/grumpy-reviewer.lock.yml @@ -403,6 +403,12 @@ jobs: run: npm install -g @openai/codex@latest - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -648,8 +654,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/issue-arborist.lock.yml b/.github/workflows/issue-arborist.lock.yml index 3888dc14f9d..36fce649834 100644 --- a/.github/workflows/issue-arborist.lock.yml +++ b/.github/workflows/issue-arborist.lock.yml @@ -340,6 +340,12 @@ jobs: run: npm install -g @openai/codex@latest - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -608,8 +614,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 019c3ea65c0..3f077830aba 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -707,6 +707,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -912,8 +918,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/issue-triage-agent.lock.yml b/.github/workflows/issue-triage-agent.lock.yml index afe6cb5f610..de093df35a1 100644 --- a/.github/workflows/issue-triage-agent.lock.yml +++ b/.github/workflows/issue-triage-agent.lock.yml @@ -308,6 +308,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -507,8 +513,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/org-health-report.lock.yml b/.github/workflows/org-health-report.lock.yml index cc1274c78b5..efe6b849203 100644 --- a/.github/workflows/org-health-report.lock.yml +++ b/.github/workflows/org-health-report.lock.yml @@ -379,6 +379,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -579,8 +585,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/plan.lock.yml b/.github/workflows/plan.lock.yml index 77699774fd5..b4072d2ce75 100644 --- a/.github/workflows/plan.lock.yml +++ b/.github/workflows/plan.lock.yml @@ -382,6 +382,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -604,8 +610,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/pr-triage-agent.lock.yml b/.github/workflows/pr-triage-agent.lock.yml index 5a0832aa8c9..b1314df4466 100644 --- a/.github/workflows/pr-triage-agent.lock.yml +++ b/.github/workflows/pr-triage-agent.lock.yml @@ -343,6 +343,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -576,8 +582,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index 0c16223ca75..0a7a4097e35 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -470,6 +470,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 ghcr.io/github/serena-mcp-server:latest node:lts-alpine - name: Install gh-aw extension @@ -750,8 +756,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/refiner.lock.yml b/.github/workflows/refiner.lock.yml index df70b16840c..bb29c98fd13 100644 --- a/.github/workflows/refiner.lock.yml +++ b/.github/workflows/refiner.lock.yml @@ -352,6 +352,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -568,8 +574,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index 3340a9d0222..4db98327770 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -481,6 +481,12 @@ jobs: run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 - name: Install Claude Code CLI run: npm install -g @anthropic-ai/claude-code@latest + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 mcp/arxiv-mcp-server mcp/markitdown node:lts-alpine - name: Write Safe Outputs Config @@ -711,8 +717,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/smoke-agent-all-merged.lock.yml b/.github/workflows/smoke-agent-all-merged.lock.yml index 265921efe5c..b06e08caae5 100644 --- a/.github/workflows/smoke-agent-all-merged.lock.yml +++ b/.github/workflows/smoke-agent-all-merged.lock.yml @@ -358,6 +358,12 @@ jobs: run: npm install -g @openai/codex@latest - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -565,8 +571,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "merged", "repos": "all" } diff --git a/.github/workflows/smoke-agent-all-none.lock.yml b/.github/workflows/smoke-agent-all-none.lock.yml index c359ba2981b..89b05c8c32a 100644 --- a/.github/workflows/smoke-agent-all-none.lock.yml +++ b/.github/workflows/smoke-agent-all-none.lock.yml @@ -358,6 +358,12 @@ jobs: run: npm install -g @openai/codex@latest - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -565,8 +571,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", "repos": "all" } diff --git a/.github/workflows/smoke-agent-public-approved.lock.yml b/.github/workflows/smoke-agent-public-approved.lock.yml index c7e920a336c..28070e18847 100644 --- a/.github/workflows/smoke-agent-public-approved.lock.yml +++ b/.github/workflows/smoke-agent-public-approved.lock.yml @@ -358,6 +358,12 @@ jobs: run: npm install -g @openai/codex@latest - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -591,8 +597,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "public" } diff --git a/.github/workflows/smoke-agent-public-none.lock.yml b/.github/workflows/smoke-agent-public-none.lock.yml index d0387021e5c..67049276bb3 100644 --- a/.github/workflows/smoke-agent-public-none.lock.yml +++ b/.github/workflows/smoke-agent-public-none.lock.yml @@ -358,6 +358,12 @@ jobs: run: npm install -g @openai/codex@latest - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -565,8 +571,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", "repos": "public" } diff --git a/.github/workflows/smoke-agent-scoped-approved.lock.yml b/.github/workflows/smoke-agent-scoped-approved.lock.yml index a8a43b40815..72a5878b573 100644 --- a/.github/workflows/smoke-agent-scoped-approved.lock.yml +++ b/.github/workflows/smoke-agent-scoped-approved.lock.yml @@ -358,6 +358,12 @@ jobs: run: npm install -g @openai/codex@latest - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -565,8 +571,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": [ "github/gh-aw", diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 64352136425..2de12f5e155 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -430,6 +430,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -637,8 +643,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/weekly-blog-post-writer.lock.yml b/.github/workflows/weekly-blog-post-writer.lock.yml index 93461eb84de..100a4197027 100644 --- a/.github/workflows/weekly-blog-post-writer.lock.yml +++ b/.github/workflows/weekly-blog-post-writer.lock.yml @@ -405,6 +405,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Install gh-aw extension @@ -748,8 +754,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": [ "github/gh-aw" diff --git a/.github/workflows/weekly-issue-summary.lock.yml b/.github/workflows/weekly-issue-summary.lock.yml index 51602eceeff..248c73d6fc9 100644 --- a/.github/workflows/weekly-issue-summary.lock.yml +++ b/.github/workflows/weekly-issue-summary.lock.yml @@ -359,6 +359,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -559,8 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/weekly-safe-outputs-spec-review.lock.yml b/.github/workflows/weekly-safe-outputs-spec-review.lock.yml index f03ce960760..d8d96e6ad2f 100644 --- a/.github/workflows/weekly-safe-outputs-spec-review.lock.yml +++ b/.github/workflows/weekly-safe-outputs-spec-review.lock.yml @@ -320,6 +320,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -517,8 +523,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/.github/workflows/workflow-generator.lock.yml b/.github/workflows/workflow-generator.lock.yml index 9a58125d578..596cc8d7914 100644 --- a/.github/workflows/workflow-generator.lock.yml +++ b/.github/workflows/workflow-generator.lock.yml @@ -359,6 +359,12 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5 + - name: Parse guard list variables + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh - name: Download container images run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config @@ -600,8 +606,8 @@ jobs: }, "guard-policies": { "allow-only": { - "approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}, - "blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}, + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", "repos": "all" } diff --git a/actions/setup/sh/parse_guard_list.sh b/actions/setup/sh/parse_guard_list.sh new file mode 100755 index 00000000000..77b9df230aa --- /dev/null +++ b/actions/setup/sh/parse_guard_list.sh @@ -0,0 +1,84 @@ +#!/bin/bash +set -eo pipefail + +# parse_guard_list.sh - Parse comma/newline-separated guard policy lists into JSON arrays +# +# Reads the combined extra (static or user-expression) and org/repo variable values for +# blocked-users and approval-labels, merges them, validates each item, and writes the +# resulting JSON arrays to $GITHUB_OUTPUT for use in the MCP gateway config step. +# +# Environment variables (all optional, default empty): +# GH_AW_BLOCKED_USERS_EXTRA - Static items or user-expression value for blocked-users +# GH_AW_BLOCKED_USERS_VAR - Value of vars.GH_AW_GITHUB_BLOCKED_USERS (fallback) +# GH_AW_APPROVAL_LABELS_EXTRA - Static items or user-expression value for approval-labels +# GH_AW_APPROVAL_LABELS_VAR - Value of vars.GH_AW_GITHUB_APPROVAL_LABELS (fallback) +# +# Outputs (to $GITHUB_OUTPUT): +# blocked_users - JSON array, e.g. ["spam-bot","bad-actor"] or [] +# approval_labels - JSON array, e.g. ["human-reviewed"] or [] +# +# Exit codes: +# 0 - Parsed successfully +# 1 - An item is invalid (empty after trimming) + +# parse_list converts a comma/newline-separated string into a JSON array. +# It trims whitespace from each item, skips empty items, validates that each +# remaining item is non-empty, and uses jq to produce a well-formed JSON array. +# Exits 1 if any item is empty after trimming. +parse_list() { + local input="$1" + local field_name="$2" + + if [ -z "$input" ]; then + echo "[]" + return 0 + fi + + local items=() + while IFS= read -r item || [ -n "$item" ]; do + # Trim leading whitespace + item="${item#"${item%%[![:space:]]*}"}" + # Trim trailing whitespace + item="${item%"${item##*[![:space:]]}"}" + if [ -n "$item" ]; then + items+=("$item") + fi + done < <(printf '%s' "$input" | tr ',' '\n') + + if [ "${#items[@]}" -eq 0 ]; then + echo "[]" + return 0 + fi + + # Format as a JSON array using jq, which handles all necessary escaping. + # jq -R reads each line as a raw string; jq -sc collects into a JSON array. + printf '%s\n' "${items[@]}" | jq -R . | jq -sc . +} + +# Combine extra and var inputs for each field. +# The script always reads both GH_AW_*_EXTRA and GH_AW_*_VAR and joins them +# with a comma so parse_list sees a single combined input. +combine_inputs() { + local extra="${1:-}" + local var="${2:-}" + if [ -n "$extra" ] && [ -n "$var" ]; then + printf '%s,%s' "$extra" "$var" + elif [ -n "$extra" ]; then + printf '%s' "$extra" + else + printf '%s' "$var" + fi +} + +BLOCKED_INPUT=$(combine_inputs "${GH_AW_BLOCKED_USERS_EXTRA:-}" "${GH_AW_BLOCKED_USERS_VAR:-}") +APPROVAL_INPUT=$(combine_inputs "${GH_AW_APPROVAL_LABELS_EXTRA:-}" "${GH_AW_APPROVAL_LABELS_VAR:-}") + +blocked_users_json=$(parse_list "$BLOCKED_INPUT" "blocked-users") +approval_labels_json=$(parse_list "$APPROVAL_INPUT" "approval-labels") + +echo "blocked_users=${blocked_users_json}" >> "$GITHUB_OUTPUT" +echo "approval_labels=${approval_labels_json}" >> "$GITHUB_OUTPUT" + +echo "Guard policy lists parsed successfully" +echo " blocked-users: ${blocked_users_json}" +echo " approval-labels: ${approval_labels_json}" diff --git a/pkg/cli/compile_guard_policy_test.go b/pkg/cli/compile_guard_policy_test.go index fa0c16ca8a2..dea71d9927b 100644 --- a/pkg/cli/compile_guard_policy_test.go +++ b/pkg/cli/compile_guard_policy_test.go @@ -189,12 +189,13 @@ This workflow uses min-integrity without specifying repos. assert.Contains(t, lockFileContent, `"allow-only"`, "Compiled lock file must include allow-only policy") assert.Contains(t, lockFileContent, `"min-integrity": "approved"`, "Compiled lock file must include min-integrity=approved") assert.Contains(t, lockFileContent, `"repos": "all"`, "Compiled lock file must default repos to 'all'") - // Fallback expressions for blocked-users and approval-labels should be injected automatically - // using toJSON() to ensure proper JSON encoding at runtime. - assert.Contains(t, lockFileContent, `"blocked-users": ${{ toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS || '') }}`, - "Compiled lock file must inject blocked-users toJSON fallback expression") - assert.Contains(t, lockFileContent, `"approval-labels": ${{ toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS || '') }}`, - "Compiled lock file must inject approval-labels toJSON fallback expression") + // The parse-guard-vars step is injected to parse variables into JSON arrays at runtime. + assert.Contains(t, lockFileContent, `id: parse-guard-vars`, "Compiled lock file must include parse-guard-vars step") + assert.Contains(t, lockFileContent, `steps.parse-guard-vars.outputs.blocked_users`, "Compiled lock file must reference blocked_users step output") + assert.Contains(t, lockFileContent, `steps.parse-guard-vars.outputs.approval_labels`, "Compiled lock file must reference approval_labels step output") + // The step must include the fallback variable env vars. + assert.Contains(t, lockFileContent, `GH_AW_BLOCKED_USERS_VAR`, "Compiled lock file must pass GH_AW_BLOCKED_USERS_VAR to parse step") + assert.Contains(t, lockFileContent, `GH_AW_APPROVAL_LABELS_VAR`, "Compiled lock file must pass GH_AW_APPROVAL_LABELS_VAR to parse step") } // TestGuardPolicyBlockedUsersApprovalLabelsCompiledOutput verifies that blocked-users and @@ -238,16 +239,18 @@ This workflow uses blocked-users and approval-labels. require.NoError(t, err, "Failed to read compiled lock file") lockFileContent := string(lockFileBytes) - // With union semantics, blocked-users and approval-labels are rendered as a JSON array - // containing the explicit values and a toJSON() fallback expression as the last element. + // The parse-guard-vars step receives static values via GH_AW_BLOCKED_USERS_EXTRA and + // GH_AW_APPROVAL_LABELS_EXTRA at compile time, and parses the GH_AW_GITHUB_* fallback + // variables at runtime to produce proper JSON arrays. + assert.Contains(t, lockFileContent, `id: parse-guard-vars`, "Compiled lock file must include parse-guard-vars step") + assert.Contains(t, lockFileContent, `GH_AW_BLOCKED_USERS_EXTRA: spam-bot,compromised-user`, "Compiled lock file must include static blocked-users in step env") + assert.Contains(t, lockFileContent, `GH_AW_BLOCKED_USERS_VAR`, "Compiled lock file must include GH_AW_BLOCKED_USERS_VAR in step env") + assert.Contains(t, lockFileContent, `GH_AW_APPROVAL_LABELS_EXTRA: human-reviewed,safe-for-agent`, "Compiled lock file must include static approval-labels in step env") + assert.Contains(t, lockFileContent, `GH_AW_APPROVAL_LABELS_VAR`, "Compiled lock file must include GH_AW_APPROVAL_LABELS_VAR in step env") assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users in the guard-policies allow-only block") - assert.Contains(t, lockFileContent, `spam-bot`, "Compiled lock file must include spam-bot in blocked-users") - assert.Contains(t, lockFileContent, `compromised-user`, "Compiled lock file must include compromised-user in blocked-users") - assert.Contains(t, lockFileContent, `toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must include blocked-users toJSON fallback") + assert.Contains(t, lockFileContent, `steps.parse-guard-vars.outputs.blocked_users`, "Compiled lock file must reference blocked_users step output") assert.Contains(t, lockFileContent, `"approval-labels"`, "Compiled lock file must include approval-labels in the guard-policies allow-only block") - assert.Contains(t, lockFileContent, `human-reviewed`, "Compiled lock file must include human-reviewed in approval-labels") - assert.Contains(t, lockFileContent, `safe-for-agent`, "Compiled lock file must include safe-for-agent in approval-labels") - assert.Contains(t, lockFileContent, `toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS`, "Compiled lock file must include approval-labels toJSON fallback") + assert.Contains(t, lockFileContent, `steps.parse-guard-vars.outputs.approval_labels`, "Compiled lock file must reference approval_labels step output") } // TestGuardPolicyBlockedUsersExpressionCompiledOutput verifies that blocked-users as a GitHub @@ -286,14 +289,17 @@ This workflow passes blocked-users and approval-labels as expressions. require.NoError(t, err, "Failed to read compiled lock file") lockFileContent := string(lockFileBytes) - // Expressions should be unioned with the GH_AW_GITHUB_* fallback in an array. - // The fallback uses toJSON() for proper JSON encoding. + // The parse-guard-vars step receives user-provided expressions via GH_AW_BLOCKED_USERS_EXTRA + // and GH_AW_APPROVAL_LABELS_EXTRA; GitHub Actions evaluates the expressions at runtime. + assert.Contains(t, lockFileContent, `id: parse-guard-vars`, "Compiled lock file must include parse-guard-vars step") + assert.Contains(t, lockFileContent, `GH_AW_BLOCKED_USERS_EXTRA: ${{ vars.BLOCKED_USERS }}`, "Compiled lock file must pass user expression to blocked_users extra") + assert.Contains(t, lockFileContent, `GH_AW_BLOCKED_USERS_VAR`, "Compiled lock file must include GH_AW_BLOCKED_USERS_VAR in step env") + assert.Contains(t, lockFileContent, `GH_AW_APPROVAL_LABELS_EXTRA: ${{ vars.APPROVAL_LABELS }}`, "Compiled lock file must pass user expression to approval_labels extra") + assert.Contains(t, lockFileContent, `GH_AW_APPROVAL_LABELS_VAR`, "Compiled lock file must include GH_AW_APPROVAL_LABELS_VAR in step env") assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users") - assert.Contains(t, lockFileContent, `vars.BLOCKED_USERS`, "Compiled lock file must preserve the blocked-users expression") - assert.Contains(t, lockFileContent, `toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must union blocked-users with toJSON fallback") + assert.Contains(t, lockFileContent, `steps.parse-guard-vars.outputs.blocked_users`, "Compiled lock file must reference blocked_users step output") assert.Contains(t, lockFileContent, `"approval-labels"`, "Compiled lock file must include approval-labels") - assert.Contains(t, lockFileContent, `vars.APPROVAL_LABELS`, "Compiled lock file must preserve the approval-labels expression") - assert.Contains(t, lockFileContent, `toJSON(vars.GH_AW_GITHUB_APPROVAL_LABELS`, "Compiled lock file must union approval-labels with toJSON fallback") + assert.Contains(t, lockFileContent, `steps.parse-guard-vars.outputs.approval_labels`, "Compiled lock file must reference approval_labels step output") } // TestGuardPolicyBlockedUsersCommaSeparatedCompiledOutput verifies that a static @@ -331,10 +337,11 @@ This workflow passes blocked-users as a comma-separated string. require.NoError(t, err, "Failed to read compiled lock file") lockFileContent := string(lockFileBytes) - // With union semantics, static comma-separated strings are split and then combined with - // the GH_AW_GITHUB_BLOCKED_USERS toJSON fallback into a JSON array. + // Static comma-separated values are passed to the parse step via GH_AW_BLOCKED_USERS_EXTRA + // at compile time; the step parses them at runtime into a JSON array. + assert.Contains(t, lockFileContent, `id: parse-guard-vars`, "Compiled lock file must include parse-guard-vars step") + assert.Contains(t, lockFileContent, `GH_AW_BLOCKED_USERS_EXTRA: spam-bot,compromised-user`, "Compiled lock file must include parsed static items in step env") + assert.Contains(t, lockFileContent, `GH_AW_BLOCKED_USERS_VAR`, "Compiled lock file must include GH_AW_BLOCKED_USERS_VAR in step env") assert.Contains(t, lockFileContent, `"blocked-users"`, "Compiled lock file must include blocked-users") - assert.Contains(t, lockFileContent, `spam-bot`, "Compiled lock file must split spam-bot from comma-separated string") - assert.Contains(t, lockFileContent, `compromised-user`, "Compiled lock file must split compromised-user from comma-separated string") - assert.Contains(t, lockFileContent, `toJSON(vars.GH_AW_GITHUB_BLOCKED_USERS`, "Compiled lock file must union with blocked-users toJSON fallback") + assert.Contains(t, lockFileContent, `steps.parse-guard-vars.outputs.blocked_users`, "Compiled lock file must reference blocked_users step output") } diff --git a/pkg/workflow/compiler_github_mcp_steps.go b/pkg/workflow/compiler_github_mcp_steps.go index 4cc4c733221..4b8a33362ae 100644 --- a/pkg/workflow/compiler_github_mcp_steps.go +++ b/pkg/workflow/compiler_github_mcp_steps.go @@ -132,3 +132,70 @@ func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Build yaml.WriteString(modifiedStep) } } + +// generateParseGuardVarsStep generates a step that parses the blocked-users and +// approval-labels variables at runtime into proper JSON arrays. +// +// The step is only emitted when explicit guard policies are configured (min-integrity or +// allowed-repos set), because only then does the guard-policies block reference +// `steps.parse-guard-vars.outputs.*`. +// +// The step runs parse_guard_list.sh which: +// - Accepts GH_AW_BLOCKED_USERS_EXTRA / GH_AW_APPROVAL_LABELS_EXTRA for compile-time +// static items or user-provided expressions. +// - Accepts GH_AW_BLOCKED_USERS_VAR / GH_AW_APPROVAL_LABELS_VAR for the +// GH_AW_GITHUB_* org/repo variable fallbacks. +// - Splits all inputs on commas and newlines, trims whitespace, removes empty entries. +// - Outputs `blocked_users` and `approval_labels` as JSON arrays via $GITHUB_OUTPUT. +// - Fails the step if any item is invalid. +func (c *Compiler) generateParseGuardVarsStep(yaml *strings.Builder, data *WorkflowData) { + githubTool, hasGitHub := data.Tools["github"] + if !hasGitHub || githubTool == false { + return + } + + // Only generate the step when guard policies are configured. + if len(getGitHubGuardPolicies(githubTool)) == 0 { + return + } + + githubConfigLog.Print("Generating parse-guard-vars step for blocked-users and approval-labels") + + // Determine the compile-time static values (or user expression) for each field. + // These come from the parsed tools config so we don't lose data from the raw map. + var blockedUsersExtra, approvalLabelsExtra string + + if data.ParsedTools != nil && data.ParsedTools.GitHub != nil { + gh := data.ParsedTools.GitHub + switch { + case len(gh.BlockedUsers) > 0: + // Static list from frontmatter — join as comma-separated for the env var. + blockedUsersExtra = strings.Join(gh.BlockedUsers, ",") + case gh.BlockedUsersExpr != "": + // User-provided GitHub Actions expression — passed verbatim; GHA evaluates it. + blockedUsersExtra = gh.BlockedUsersExpr + } + switch { + case len(gh.ApprovalLabels) > 0: + approvalLabelsExtra = strings.Join(gh.ApprovalLabels, ",") + case gh.ApprovalLabelsExpr != "": + approvalLabelsExtra = gh.ApprovalLabelsExpr + } + } + + yaml.WriteString(" - name: Parse guard list variables\n") + yaml.WriteString(" id: parse-guard-vars\n") + yaml.WriteString(" env:\n") + + if blockedUsersExtra != "" { + fmt.Fprintf(yaml, " GH_AW_BLOCKED_USERS_EXTRA: %s\n", blockedUsersExtra) + } + fmt.Fprintf(yaml, " GH_AW_BLOCKED_USERS_VAR: ${{ vars.%s || '' }}\n", constants.EnvVarGitHubBlockedUsers) + + if approvalLabelsExtra != "" { + fmt.Fprintf(yaml, " GH_AW_APPROVAL_LABELS_EXTRA: %s\n", approvalLabelsExtra) + } + fmt.Fprintf(yaml, " GH_AW_APPROVAL_LABELS_VAR: ${{ vars.%s || '' }}\n", constants.EnvVarGitHubApprovalLabels) + + yaml.WriteString(" run: bash ${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh\n") +} diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go index 6f99154f9d8..581d1ab9bc4 100644 --- a/pkg/workflow/compiler_yaml_main_job.go +++ b/pkg/workflow/compiler_yaml_main_job.go @@ -284,6 +284,9 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat // Add GitHub MCP lockdown detection step if needed c.generateGitHubMCPLockdownDetectionStep(yaml, data) + // Add step to parse blocked-users and approval-labels guard variables into JSON arrays + c.generateParseGuardVarsStep(yaml, data) + // Add GitHub MCP app token minting step if configured c.generateGitHubMCPAppTokenMintingStep(yaml, data) diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 89f0e08c83b..1b6c772da22 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -260,8 +260,6 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { repos, hasRepos = toolConfig["repos"] } integrity, hasIntegrity := toolConfig["min-integrity"] - blockedUsers := toolConfig["blocked-users"] - approvalLabels := toolConfig["approval-labels"] if hasRepos || hasIntegrity { policy := map[string]any{} if hasRepos { @@ -274,11 +272,11 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { if hasIntegrity { policy["min-integrity"] = integrity } - // Always union blocked-users with the GH_AW_GITHUB_BLOCKED_USERS org/repo variable so - // that a centrally-configured variable extends the per-workflow list. - policy["blocked-users"] = unionWithEnvFallback(blockedUsers, constants.EnvVarGitHubBlockedUsers) - // Always union approval-labels with the GH_AW_GITHUB_APPROVAL_LABELS org/repo variable. - policy["approval-labels"] = unionWithEnvFallback(approvalLabels, constants.EnvVarGitHubApprovalLabels) + // blocked-users and approval-labels are parsed at runtime by the parse-guard-vars step. + // The step outputs proper JSON arrays (split on comma/newline, validated, jq-encoded) + // from both the compile-time static values and the GH_AW_GITHUB_* org/repo variables. + policy["blocked-users"] = guardExprSentinel + "${{ steps.parse-guard-vars.outputs.blocked_users }}" + policy["approval-labels"] = guardExprSentinel + "${{ steps.parse-guard-vars.outputs.approval_labels }}" return map[string]any{ "allow-only": policy, } @@ -287,56 +285,6 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { return nil } -// unionWithEnvFallback builds a JSON-safe value that unions any explicitly configured -// blocked-users / approval-labels values with an org/repo variable fallback. -// -// For the fallback expression, the returned string is prefixed with guardExprSentinel so -// that renderGuardPoliciesJSON can strip the surrounding JSON string quotes and emit the -// toJSON() expression verbatim. At runtime, GitHub Actions evaluates toJSON() which properly -// JSON-encodes the variable value, preventing JSON injection even if the value contains -// double quotes or backslashes. -// -// - nil / not set → sentinel-prefixed toJSON fallback expression (rendered without quotes) -// - []any or []string → JSON array of static string values with the sentinel fallback as last element -// - string expression → JSON array of [expression, sentinel fallback] -func unionWithEnvFallback(existing any, envVar string) any { - // The sentinel prefix tells renderGuardPoliciesJSON to un-quote this value and emit the - // toJSON() expression verbatim. toJSON() ensures the variable value is properly - // JSON-encoded at runtime. - fallback := guardExprSentinel + fmt.Sprintf(`${{ toJSON(vars.%s || '') }}`, envVar) - - switch v := existing.(type) { - case []any: - parts := make([]any, 0, len(v)+1) - for _, item := range v { - if str, ok := item.(string); ok && str != "" { - parts = append(parts, str) - } - } - parts = append(parts, fallback) - return parts - case []string: - parts := make([]any, 0, len(v)+1) - for _, s := range v { - if s != "" { - parts = append(parts, s) - } - } - parts = append(parts, fallback) - return parts - case string: - if v != "" { - // User-provided expression (e.g. "${{ vars.CUSTOM }}") unioned with the fallback. - return []any{v, fallback} - } - // Fallback only: sentinel-prefixed toJSON expression (no surrounding string quotes). - return fallback - default: - // Fallback only. - return fallback - } -} - // 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: