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..2ad241fad47 --- /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 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 1a2fe4a30a9..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,6 +564,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 632779b58a9..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,6 +669,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 e5fdf9d6fc6..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,6 +571,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 0880ea10028..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,6 +654,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 7e642a67ea9..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,6 +614,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 7fe766aaa1e..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,6 +918,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 6eb15ad7fbe..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,6 +513,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 89b3c32ebcf..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,6 +585,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 201f3515609..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,6 +610,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 f6fa5111ff5..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,6 +582,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 01ce7f57d62..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,6 +756,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 cb8a1743b61..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,6 +574,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 20398bf38a0..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,6 +717,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 15882267628..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,6 +571,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 bb7f43e231d..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,6 +571,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 a20963be3cb..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,6 +597,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 e01dd61a1b7..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,6 +571,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 bb2f172d2dd..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,6 +571,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 0f241d7aecf..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,6 +643,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 fd9b0db05f2..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,6 +754,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 35f5b565e21..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,6 +565,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 3d4a9ddc2e4..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,6 +523,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 aebfbf0a1e2..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,6 +606,8 @@ jobs: }, "guard-policies": { "allow-only": { + "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 9dc15a850e8..dea71d9927b 100644 --- a/pkg/cli/compile_guard_policy_test.go +++ b/pkg/cli/compile_guard_policy_test.go @@ -183,8 +183,165 @@ 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'") + // 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 +// 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) + // 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, `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, `steps.parse-guard-vars.outputs.approval_labels`, "Compiled lock file must reference approval_labels step output") +} + +// 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) + // 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, `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, `steps.parse-guard-vars.outputs.approval_labels`, "Compiled lock file must reference approval_labels step output") +} + +// 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 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, `steps.parse-guard-vars.outputs.blocked_users`, "Compiled lock file must reference blocked_users step output") } 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/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index ba3891234d2..fc1dff4be0d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3206,6 +3206,42 @@ "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": { + "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": { + "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", "description": "GitHub App configuration for token minting. When configured, a GitHub App installation access token is minted at workflow start and used instead of the default token. This token overrides any custom github-token setting and provides fine-grained permissions matching the agent job requirements.", diff --git a/pkg/workflow/compiler_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 3a86a3e2465..1b6c772da22 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -242,11 +242,15 @@ 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, // so this function will never be called with repos but without min-integrity in practice. +// 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 { @@ -268,6 +272,11 @@ func getGitHubGuardPolicies(githubTool any) map[string]any { if hasIntegrity { policy["min-integrity"] = integrity } + // 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, } 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. diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index ae9444288c1..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)) @@ -249,6 +275,46 @@ 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 + } 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)) + 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 + } 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 0322093286b..fe571c8ff2a 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -299,8 +299,22 @@ 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"` + // 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 0582d093de8..1cc30658ac2 100644 --- a/pkg/workflow/tools_validation.go +++ b/pkg/workflow/tools_validation.go @@ -73,6 +73,16 @@ 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 != "" + // 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 { + 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 +119,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; 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) + return errors.New("invalid guard policy: 'github.blocked-users' entries must not be 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) + 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..8e4b38ac3d3 100644 --- a/pkg/workflow/tools_validation_test.go +++ b/pkg/workflow/tools_validation_test.go @@ -374,6 +374,149 @@ 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'", + }, + { + 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 {