diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 985da6e8d21..f3951ed2ba7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -398,7 +398,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 needs: [integration] # test dependency removed - download-artifact fetches by name, not job dependency - if: always() # Run even if some tests fail to report coverage + if: always() && github.ref == 'refs/heads/main' # Only run on main branch; run even if some tests fail to report coverage permissions: contents: read steps: @@ -422,6 +422,7 @@ jobs: - name: List downloaded artifacts run: | set -euo pipefail + mkdir -p test-results echo "Downloaded test result artifacts:" find test-results -type f -name "*.json" | sort echo "" @@ -431,11 +432,22 @@ jobs: run: | set -euo pipefail echo "Extracting test names from JSON artifacts..." - ./scripts/extract-executed-tests.sh test-results > executed-tests.txt - echo "Found $(wc -l < executed-tests.txt) executed tests" + JSON_COUNT=$(find test-results -name "*.json" -type f 2>/dev/null | wc -l) + if [ "$JSON_COUNT" -eq 0 ]; then + echo "⚠️ No test result artifacts found. Tests may not have run or were cancelled." + touch executed-tests.txt + else + ./scripts/extract-executed-tests.sh test-results > executed-tests.txt + echo "Found $(wc -l < executed-tests.txt) executed tests" + fi - name: Compare test coverage run: | + if [ ! -s executed-tests.txt ]; then + echo "⚠️ No test execution data available. Skipping coverage comparison." + echo "This typically happens when test jobs were cancelled or did not run." + exit 0 + fi ./scripts/compare-test-coverage.sh all-tests.txt executed-tests.txt - name: Upload test coverage report diff --git a/.github/workflows/contribution-check.lock.yml b/.github/workflows/contribution-check.lock.yml index a4786f80603..c293e5614c5 100644 --- a/.github/workflows/contribution-check.lock.yml +++ b/.github/workflows/contribution-check.lock.yml @@ -334,6 +334,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -572,7 +573,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index 138674636f1..97f8e55c649 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -414,6 +414,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -675,7 +676,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/discussion-task-miner.lock.yml b/.github/workflows/discussion-task-miner.lock.yml index 870b603673c..3a7d07a8361 100644 --- a/.github/workflows/discussion-task-miner.lock.yml +++ b/.github/workflows/discussion-task-miner.lock.yml @@ -357,6 +357,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -575,7 +576,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/grumpy-reviewer.lock.yml b/.github/workflows/grumpy-reviewer.lock.yml index 56e8dd27b48..2c23df856a0 100644 --- a/.github/workflows/grumpy-reviewer.lock.yml +++ b/.github/workflows/grumpy-reviewer.lock.yml @@ -418,6 +418,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -668,7 +669,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/issue-arborist.lock.yml b/.github/workflows/issue-arborist.lock.yml index 7c9096eb927..606ef60f59b 100644 --- a/.github/workflows/issue-arborist.lock.yml +++ b/.github/workflows/issue-arborist.lock.yml @@ -347,6 +347,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -620,7 +621,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 0eb7687e015..1b6529f69c2 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -709,6 +709,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -919,7 +920,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/issue-triage-agent.lock.yml b/.github/workflows/issue-triage-agent.lock.yml index c94048e27ff..63ff0b7f93e 100644 --- a/.github/workflows/issue-triage-agent.lock.yml +++ b/.github/workflows/issue-triage-agent.lock.yml @@ -315,6 +315,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -519,7 +520,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/org-health-report.lock.yml b/.github/workflows/org-health-report.lock.yml index 7e9dbab15ae..da372c9a01e 100644 --- a/.github/workflows/org-health-report.lock.yml +++ b/.github/workflows/org-health-report.lock.yml @@ -387,6 +387,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -592,7 +593,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/plan.lock.yml b/.github/workflows/plan.lock.yml index 95cd1f6c76a..4913b8bf17f 100644 --- a/.github/workflows/plan.lock.yml +++ b/.github/workflows/plan.lock.yml @@ -385,6 +385,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -612,7 +613,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/pr-triage-agent.lock.yml b/.github/workflows/pr-triage-agent.lock.yml index 4278f48fc22..437ed0c7ffc 100644 --- a/.github/workflows/pr-triage-agent.lock.yml +++ b/.github/workflows/pr-triage-agent.lock.yml @@ -352,6 +352,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -590,7 +591,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index 8c60f4b6648..95dd84de33f 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -509,6 +509,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -794,7 +795,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/refiner.lock.yml b/.github/workflows/refiner.lock.yml index b20a64044fb..f55e8ba834d 100644 --- a/.github/workflows/refiner.lock.yml +++ b/.github/workflows/refiner.lock.yml @@ -361,6 +361,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -582,7 +583,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index dc490472283..442f87f0e70 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -482,6 +482,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -717,7 +718,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/smoke-agent-all-merged.lock.yml b/.github/workflows/smoke-agent-all-merged.lock.yml index 07b74ac5cf8..e09843d084a 100644 --- a/.github/workflows/smoke-agent-all-merged.lock.yml +++ b/.github/workflows/smoke-agent-all-merged.lock.yml @@ -369,6 +369,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -581,7 +582,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "merged", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/smoke-agent-all-none.lock.yml b/.github/workflows/smoke-agent-all-none.lock.yml index 0580cf462e1..4c1ffd92573 100644 --- a/.github/workflows/smoke-agent-all-none.lock.yml +++ b/.github/workflows/smoke-agent-all-none.lock.yml @@ -369,6 +369,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -581,7 +582,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/smoke-agent-public-approved.lock.yml b/.github/workflows/smoke-agent-public-approved.lock.yml index a237e9e200a..506c9814235 100644 --- a/.github/workflows/smoke-agent-public-approved.lock.yml +++ b/.github/workflows/smoke-agent-public-approved.lock.yml @@ -369,6 +369,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -607,7 +608,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "public" + "repos": "public", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/smoke-agent-public-none.lock.yml b/.github/workflows/smoke-agent-public-none.lock.yml index 5aa0a6c5899..231f4f469ce 100644 --- a/.github/workflows/smoke-agent-public-none.lock.yml +++ b/.github/workflows/smoke-agent-public-none.lock.yml @@ -369,6 +369,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -581,7 +582,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "none", - "repos": "public" + "repos": "public", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/smoke-agent-scoped-approved.lock.yml b/.github/workflows/smoke-agent-scoped-approved.lock.yml index ba6ddcefe77..619e57d9bc1 100644 --- a/.github/workflows/smoke-agent-scoped-approved.lock.yml +++ b/.github/workflows/smoke-agent-scoped-approved.lock.yml @@ -369,6 +369,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -584,7 +585,8 @@ jobs: "repos": [ "github/gh-aw", "github/*" - ] + ], + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 057d26efd73..17a582d57ee 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -435,6 +435,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -647,7 +648,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/weekly-blog-post-writer.lock.yml b/.github/workflows/weekly-blog-post-writer.lock.yml index d36403b7a96..cf88d50f3f3 100644 --- a/.github/workflows/weekly-blog-post-writer.lock.yml +++ b/.github/workflows/weekly-blog-post-writer.lock.yml @@ -423,6 +423,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -705,7 +706,8 @@ jobs: "min-integrity": "approved", "repos": [ "github/gh-aw" - ] + ], + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/weekly-issue-summary.lock.yml b/.github/workflows/weekly-issue-summary.lock.yml index 26030373efc..710f330e60f 100644 --- a/.github/workflows/weekly-issue-summary.lock.yml +++ b/.github/workflows/weekly-issue-summary.lock.yml @@ -367,6 +367,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -572,7 +573,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/weekly-safe-outputs-spec-review.lock.yml b/.github/workflows/weekly-safe-outputs-spec-review.lock.yml index faf889d29ce..6b6dd05168c 100644 --- a/.github/workflows/weekly-safe-outputs-spec-review.lock.yml +++ b/.github/workflows/weekly-safe-outputs-spec-review.lock.yml @@ -329,6 +329,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -531,7 +532,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/.github/workflows/workflow-generator.lock.yml b/.github/workflows/workflow-generator.lock.yml index 9a47e32817a..72d154799e6 100644 --- a/.github/workflows/workflow-generator.lock.yml +++ b/.github/workflows/workflow-generator.lock.yml @@ -362,6 +362,7 @@ jobs: id: parse-guard-vars env: GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_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 @@ -608,7 +609,8 @@ jobs: "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, "min-integrity": "approved", - "repos": "all" + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} } } }, diff --git a/actions/setup/sh/parse_guard_list.sh b/actions/setup/sh/parse_guard_list.sh index 77b9df230aa..9154be7b296 100755 --- a/actions/setup/sh/parse_guard_list.sh +++ b/actions/setup/sh/parse_guard_list.sh @@ -4,17 +4,20 @@ 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. +# blocked-users, trusted-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_TRUSTED_USERS_EXTRA - Static items or user-expression value for trusted-users +# GH_AW_TRUSTED_USERS_VAR - Value of vars.GH_AW_GITHUB_TRUSTED_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 [] +# trusted_users - JSON array, e.g. ["contractor-1","partner-dev"] or [] # approval_labels - JSON array, e.g. ["human-reviewed"] or [] # # Exit codes: @@ -71,14 +74,18 @@ combine_inputs() { } BLOCKED_INPUT=$(combine_inputs "${GH_AW_BLOCKED_USERS_EXTRA:-}" "${GH_AW_BLOCKED_USERS_VAR:-}") +TRUSTED_INPUT=$(combine_inputs "${GH_AW_TRUSTED_USERS_EXTRA:-}" "${GH_AW_TRUSTED_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") +trusted_users_json=$(parse_list "$TRUSTED_INPUT" "trusted-users") approval_labels_json=$(parse_list "$APPROVAL_INPUT" "approval-labels") echo "blocked_users=${blocked_users_json}" >> "$GITHUB_OUTPUT" +echo "trusted_users=${trusted_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 " trusted-users: ${trusted_users_json}" echo " approval-labels: ${approval_labels_json}" diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index d9adca42b83..24c9e24741a 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1868,6 +1868,24 @@ tools: # expression resolving to such a list (e.g. '${{ vars.BLOCKED_USERS }}') blocked-users: "example-value" + # Guard policy: GitHub usernames whose content is elevated to 'approved' integrity + # regardless of author_association. Allows specific external contributors to bypass + # 'min-integrity' checks without lowering the global policy. Precedence: + # blocked-users > trusted-users > approval-labels > author_association. 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.TRUSTED_USERS }}'). + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Array of GitHub usernames to trust + trusted-users: [] + # Array items: GitHub username to elevate to approved integrity + + # Option 2: Comma- or newline-separated list of usernames, or a GitHub Actions + # expression resolving to such a list (e.g. '${{ vars.TRUSTED_USERS }}') + trusted-users: "example-value" + # 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 diff --git a/pkg/cli/compile_guard_policy_test.go b/pkg/cli/compile_guard_policy_test.go index dea71d9927b..56883205a8b 100644 --- a/pkg/cli/compile_guard_policy_test.go +++ b/pkg/cli/compile_guard_policy_test.go @@ -192,9 +192,11 @@ This workflow uses min-integrity without specifying repos. // 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.trusted_users`, "Compiled lock file must reference trusted_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_TRUSTED_USERS_VAR`, "Compiled lock file must pass GH_AW_TRUSTED_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") } @@ -345,3 +347,125 @@ This workflow passes blocked-users as a comma-separated string. 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") } + +// TestGuardPolicyTrustedUsersCompiledOutput verifies that trusted-users is written into the +// compiled guard-policies allow-only block and that the parse-guard-vars step receives the +// static values via GH_AW_TRUSTED_USERS_EXTRA. +func TestGuardPolicyTrustedUsersCompiledOutput(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +tools: + github: + min-integrity: approved + trusted-users: + - contractor-1 + - partner-dev + blocked-users: + - bad-actor +--- + +# Guard Policy Test + +This workflow uses trusted-users alongside blocked-users. +` + + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-guard-policy-trusted.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-trusted.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 trusted-users values via GH_AW_TRUSTED_USERS_EXTRA. + assert.Contains(t, lockFileContent, `id: parse-guard-vars`, "Compiled lock file must include parse-guard-vars step") + assert.Contains(t, lockFileContent, `GH_AW_TRUSTED_USERS_EXTRA: contractor-1,partner-dev`, "Compiled lock file must include static trusted-users in step env") + assert.Contains(t, lockFileContent, `GH_AW_TRUSTED_USERS_VAR`, "Compiled lock file must include GH_AW_TRUSTED_USERS_VAR in step env") + assert.Contains(t, lockFileContent, `GH_AW_BLOCKED_USERS_EXTRA: bad-actor`, "Compiled lock file must include static blocked-users in step env") + assert.Contains(t, lockFileContent, `"trusted-users"`, "Compiled lock file must include trusted-users in the guard-policies allow-only block") + assert.Contains(t, lockFileContent, `steps.parse-guard-vars.outputs.trusted_users`, "Compiled lock file must reference trusted_users step output") + 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") +} + +// TestGuardPolicyTrustedUsersExpressionCompiledOutput verifies that a trusted-users GitHub +// Actions expression is passed through as a string in the compiled guard-policies block. +func TestGuardPolicyTrustedUsersExpressionCompiledOutput(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +tools: + github: + min-integrity: approved + trusted-users: "${{ vars.TRUSTED_USERS }}" +--- + +# Guard Policy Test + +This workflow passes trusted-users as a GitHub Actions expression. +` + + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-guard-policy-trusted-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-trusted-expr.lock.yml") + lockFileBytes, err := os.ReadFile(lockFilePath) + require.NoError(t, err, "Failed to read compiled lock file") + + lockFileContent := string(lockFileBytes) + assert.Contains(t, lockFileContent, `id: parse-guard-vars`, "Compiled lock file must include parse-guard-vars step") + assert.Contains(t, lockFileContent, `GH_AW_TRUSTED_USERS_EXTRA: ${{ vars.TRUSTED_USERS }}`, "Compiled lock file must pass user expression to trusted_users extra") + assert.Contains(t, lockFileContent, `GH_AW_TRUSTED_USERS_VAR`, "Compiled lock file must include GH_AW_TRUSTED_USERS_VAR in step env") + assert.Contains(t, lockFileContent, `"trusted-users"`, "Compiled lock file must include trusted-users in the guard-policies allow-only block") + assert.Contains(t, lockFileContent, `steps.parse-guard-vars.outputs.trusted_users`, "Compiled lock file must reference trusted_users step output") +} + +// TestGuardPolicyTrustedUsersRequiresMinIntegrity verifies that trusted-users cannot be set +// without a min-integrity guard policy. +func TestGuardPolicyTrustedUsersRequiresMinIntegrity(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +tools: + github: + trusted-users: + - contractor-1 +--- + +# Guard Policy Test + +This workflow sets trusted-users without min-integrity (should fail). +` + + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-guard-policy-trusted-no-integrity.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.Error(t, err, "Expected compilation to fail without min-integrity") + assert.Contains(t, err.Error(), "min-integrity", "Error should mention min-integrity requirement") +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index f719b332047..47781002bcb 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -346,6 +346,12 @@ const ( // 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" + + // EnvVarGitHubTrustedUsers is the fallback variable for the tools.github.trusted-users guard policy field. + // When trusted-users is not explicitly set in the workflow frontmatter, this variable is used as + // a comma- or newline-separated list of GitHub usernames elevated to "approved" integrity. + // Set as an org or repo variable to apply a consistent trusted user list across all workflows. + EnvVarGitHubTrustedUsers = "GH_AW_GITHUB_TRUSTED_USERS" ) // 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 64e1c1c4913..45b7b06d4ac 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3678,6 +3678,24 @@ } ] }, + "trusted-users": { + "description": "Guard policy: GitHub usernames whose content is elevated to 'approved' integrity regardless of author_association. Allows specific external contributors to bypass 'min-integrity' checks without lowering the global policy. Precedence: blocked-users > trusted-users > approval-labels > author_association. 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.TRUSTED_USERS }}').", + "oneOf": [ + { + "type": "array", + "description": "Array of GitHub usernames to trust", + "items": { + "type": "string", + "description": "GitHub username to elevate to approved integrity" + }, + "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.TRUSTED_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": [ diff --git a/pkg/workflow/cache_integrity.go b/pkg/workflow/cache_integrity.go index ec482770a90..fcd44c1192d 100644 --- a/pkg/workflow/cache_integrity.go +++ b/pkg/workflow/cache_integrity.go @@ -76,8 +76,16 @@ func buildCanonicalPolicy(github *GitHubToolConfig) string { // trusted-bots: reserved for future use (always empty today) sb.WriteString("trusted-bots:\n") - // trusted-users: reserved for future use (always empty today) + // trusted-users: sorted, lowercased, deduplicated literal list (via canonicalUserList). + // When trusted-users is provided as a GitHub Actions expression (TrustedUsersExpr), + // include it verbatim so that changing the expression produces a different hash. sb.WriteString("trusted-users:") + if github.TrustedUsersExpr != "" { + sb.WriteString("expr:") + sb.WriteString(github.TrustedUsersExpr) + } else { + sb.WriteString(canonicalUserList(github.TrustedUsers)) + } return sb.String() } diff --git a/pkg/workflow/compiler_github_mcp_steps.go b/pkg/workflow/compiler_github_mcp_steps.go index de1199b4389..dc9858c694c 100644 --- a/pkg/workflow/compiler_github_mcp_steps.go +++ b/pkg/workflow/compiler_github_mcp_steps.go @@ -133,7 +133,7 @@ func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Build } } -// generateParseGuardVarsStep generates a step that parses the blocked-users and +// generateParseGuardVarsStep generates a step that parses the blocked-users, trusted-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 @@ -141,12 +141,12 @@ func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Build // `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. +// - Accepts GH_AW_BLOCKED_USERS_EXTRA / GH_AW_TRUSTED_USERS_EXTRA / GH_AW_APPROVAL_LABELS_EXTRA +// for compile-time static items or user-provided expressions. +// - Accepts GH_AW_BLOCKED_USERS_VAR / GH_AW_TRUSTED_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. +// - Outputs `blocked_users`, `trusted_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"] @@ -159,11 +159,11 @@ func (c *Compiler) generateParseGuardVarsStep(yaml *strings.Builder, data *Workf return } - githubConfigLog.Print("Generating parse-guard-vars step for blocked-users and approval-labels") + githubConfigLog.Print("Generating parse-guard-vars step for blocked-users, trusted-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 + var blockedUsersExtra, trustedUsersExtra, approvalLabelsExtra string if data.ParsedTools != nil && data.ParsedTools.GitHub != nil { gh := data.ParsedTools.GitHub @@ -176,6 +176,12 @@ func (c *Compiler) generateParseGuardVarsStep(yaml *strings.Builder, data *Workf blockedUsersExtra = gh.BlockedUsersExpr } switch { + case len(gh.TrustedUsers) > 0: + trustedUsersExtra = strings.Join(gh.TrustedUsers, ",") + case gh.TrustedUsersExpr != "": + trustedUsersExtra = gh.TrustedUsersExpr + } + switch { case len(gh.ApprovalLabels) > 0: approvalLabelsExtra = strings.Join(gh.ApprovalLabels, ",") case gh.ApprovalLabelsExpr != "": @@ -192,6 +198,11 @@ func (c *Compiler) generateParseGuardVarsStep(yaml *strings.Builder, data *Workf } fmt.Fprintf(yaml, " GH_AW_BLOCKED_USERS_VAR: ${{ vars.%s || '' }}\n", constants.EnvVarGitHubBlockedUsers) + if trustedUsersExtra != "" { + fmt.Fprintf(yaml, " GH_AW_TRUSTED_USERS_EXTRA: %s\n", trustedUsersExtra) + } + fmt.Fprintf(yaml, " GH_AW_TRUSTED_USERS_VAR: ${{ vars.%s || '' }}\n", constants.EnvVarGitHubTrustedUsers) + if approvalLabelsExtra != "" { fmt.Fprintf(yaml, " GH_AW_APPROVAL_LABELS_EXTRA: %s\n", approvalLabelsExtra) } diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 1b6c772da22..b041fad2fd0 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -242,15 +242,15 @@ func getGitHubAllowedTools(githubTool any) []string { } // getGitHubGuardPolicies extracts guard policies from GitHub tool configuration. -// It reads the flat allowed-repos/repos/min-integrity/blocked-users/approval-labels fields +// It reads the flat allowed-repos/repos/min-integrity/blocked-users/trusted-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. +// When blocked-users, trusted-users, or approval-labels are set, their values are unioned with +// the org/repo variable fallback expressions 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 { @@ -272,10 +272,12 @@ 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. + // blocked-users, trusted-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["trusted-users"] = guardExprSentinel + "${{ steps.parse-guard-vars.outputs.trusted_users }}" policy["approval-labels"] = guardExprSentinel + "${{ steps.parse-guard-vars.outputs.approval_labels }}" return map[string]any{ "allow-only": policy, diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index a008cfa8b8d..c2393100f28 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -314,6 +314,26 @@ func parseGitHubTool(val any) *GitHubToolConfig { configMap["approval-labels"] = toAnySlice(parsed) // normalize raw map for JSON rendering } } + if trustedUsers, ok := configMap["trusted-users"].([]any); ok { + config.TrustedUsers = make([]string, 0, len(trustedUsers)) + for _, item := range trustedUsers { + if str, ok := item.(string); ok { + config.TrustedUsers = append(config.TrustedUsers, str) + } + } + } else if trustedUsers, ok := configMap["trusted-users"].([]string); ok { + config.TrustedUsers = trustedUsers + } else if trustedUsersStr, ok := configMap["trusted-users"].(string); ok { + if isGitHubActionsExpression(trustedUsersStr) { + // GitHub Actions expression: store as-is; raw map retains the string for JSON rendering. + config.TrustedUsersExpr = trustedUsersStr + } else { + // Static comma/newline-separated string: parse at compile time. + parsed := parseCommaSeparatedOrNewlineList(trustedUsersStr) + config.TrustedUsers = parsed + configMap["trusted-users"] = 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 2419c50ae40..8f6f6db6bf7 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -303,6 +303,14 @@ type GitHubToolConfig struct { // 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:"-"` + // TrustedUsers is an optional list of GitHub usernames whose content is elevated to "approved" + // integrity regardless of author_association. Takes precedence over min-integrity checks but + // not over blocked-users. Requires min-integrity to be set. + TrustedUsers []string `yaml:"trusted-users,omitempty"` + // TrustedUsersExpr holds a GitHub Actions expression (e.g. "${{ vars.TRUSTED_USERS }}") that + // resolves at runtime to a comma- or newline-separated list of trusted usernames. + // Set when the trusted-users field is a string expression rather than a literal array. + TrustedUsersExpr 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"` diff --git a/pkg/workflow/tools_validation.go b/pkg/workflow/tools_validation.go index 1cc30658ac2..ec1769bab0d 100644 --- a/pkg/workflow/tools_validation.go +++ b/pkg/workflow/tools_validation.go @@ -73,15 +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). + // blocked-users / approval-labels / trusted-users can be an array or a + // GitHub Actions expression string. hasBlockedUsers := len(github.BlockedUsers) > 0 || github.BlockedUsersExpr != "" hasApprovalLabels := len(github.ApprovalLabels) > 0 || github.ApprovalLabelsExpr != "" + hasTrustedUsers := len(github.TrustedUsers) > 0 || github.TrustedUsersExpr != "" - // 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") + // blocked-users, trusted-users, and approval-labels require a guard policy (min-integrity) + if (hasBlockedUsers || hasApprovalLabels || hasTrustedUsers) && !hasMinIntegrity { + toolsValidationLog.Printf("blocked-users/trusted-users/approval-labels without guard policy in workflow: %s", workflowName) + return errors.New("invalid guard policy: 'github.blocked-users', 'github.trusted-users', and 'github.approval-labels' require 'github.min-integrity' to be set") } // No guard policy fields present - nothing to validate @@ -135,6 +136,14 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { } } + // Validate trusted-users (must be non-empty strings; expressions are accepted as-is) + for i, user := range github.TrustedUsers { + if user == "" { + toolsValidationLog.Printf("Empty trusted-users entry at index %d in workflow: %s", i, workflowName) + return errors.New("invalid guard policy: 'github.trusted-users' 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 8e4b38ac3d3..856c32c63e8 100644 --- a/pkg/workflow/tools_validation_test.go +++ b/pkg/workflow/tools_validation_test.go @@ -416,7 +416,7 @@ func TestValidateGitHubGuardPolicy(t *testing.T) { }, }, shouldError: true, - errorMsg: "'github.blocked-users' and 'github.approval-labels' require 'github.min-integrity'", + errorMsg: "'github.min-integrity' to be set", }, { name: "approval-labels without min-integrity fails", @@ -426,7 +426,7 @@ func TestValidateGitHubGuardPolicy(t *testing.T) { }, }, shouldError: true, - errorMsg: "'github.blocked-users' and 'github.approval-labels' require 'github.min-integrity'", + errorMsg: "'github.min-integrity' to be set", }, { name: "blocked-users with empty string entry fails", @@ -461,7 +461,7 @@ func TestValidateGitHubGuardPolicy(t *testing.T) { }, }, shouldError: true, - errorMsg: "'github.blocked-users' and 'github.approval-labels' require 'github.min-integrity'", + errorMsg: "'github.min-integrity' to be set", }, { name: "blocked-users as GitHub Actions expression is valid", @@ -504,7 +504,7 @@ func TestValidateGitHubGuardPolicy(t *testing.T) { }, }, shouldError: true, - errorMsg: "'github.blocked-users' and 'github.approval-labels' require 'github.min-integrity'", + errorMsg: "'github.min-integrity' to be set", }, { name: "approval-labels as GitHub Actions expression is valid", @@ -517,6 +517,60 @@ func TestValidateGitHubGuardPolicy(t *testing.T) { }, shouldError: false, }, + { + name: "valid guard policy with trusted-users", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "approved", + "trusted-users": []any{"contractor-1", "partner-dev"}, + }, + }, + shouldError: false, + }, + { + name: "trusted-users without min-integrity fails", + toolsMap: map[string]any{ + "github": map[string]any{ + "trusted-users": []any{"contractor-1"}, + }, + }, + shouldError: true, + errorMsg: "'github.min-integrity' to be set", + }, + { + name: "trusted-users with empty string entry fails", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "approved", + "trusted-users": []any{""}, + }, + }, + shouldError: true, + errorMsg: "'github.trusted-users' entries must not be empty strings", + }, + { + name: "trusted-users as GitHub Actions expression is valid", + toolsMap: map[string]any{ + "github": map[string]any{ + "allowed-repos": "all", + "min-integrity": "approved", + "trusted-users": "${{ vars.TRUSTED_USERS }}", + }, + }, + shouldError: false, + }, + { + name: "trusted-users expression without min-integrity fails", + toolsMap: map[string]any{ + "github": map[string]any{ + "trusted-users": "${{ vars.TRUSTED_USERS }}", + }, + }, + shouldError: true, + errorMsg: "'github.min-integrity' to be set", + }, } for _, tt := range tests { diff --git a/scratchpad/github-mcp-access-control-specification.md b/scratchpad/github-mcp-access-control-specification.md index ec3dab83dfe..30f5a385ff0 100644 --- a/scratchpad/github-mcp-access-control-specification.md +++ b/scratchpad/github-mcp-access-control-specification.md @@ -140,6 +140,8 @@ A **Complete Conforming Implementation** MUST satisfy Basic Conformance and: - Block content items whose integrity level is below `min-integrity` - Parse and validate `blocked-users` configuration field - Block all content items authored by users in `blocked-users`, regardless of integrity level +- Parse and validate `trusted-users` configuration field +- Elevate content items from users in `trusted-users` to "approved" integrity regardless of author_association - Parse and validate `approval-labels` configuration field - Promote content items bearing a label in `approval-labels` to "approved" integrity level - Validate configuration at compilation time with actionable error messages @@ -266,6 +268,9 @@ tools: blocked-users: # OPTIONAL: Users whose items are always blocked - "external-bot" - "untrusted-contributor" + trusted-users: # OPTIONAL: Users elevated to "approved" integrity regardless of author_association + - "contractor-1" + - "partner-dev" approval-labels: # OPTIONAL: Labels that raise item integrity to "approved" - "approved" - "human-reviewed" @@ -712,6 +717,7 @@ The `blocked-users` field specifies GitHub usernames whose content items MUST al **Semantics**: - A content item authored by any user in `blocked-users` is denied unconditionally. - `approval-labels` cannot override a `blocked-users` exclusion. +- `trusted-users` cannot override a `blocked-users` exclusion. - This field operates orthogonally to `min-integrity`; blocked users are rejected before the integrity level check. **Use Cases**: @@ -737,7 +743,45 @@ blocked-users: Warning: blocked-users is an empty array. Omit the field entirely to have no blocked users. ``` -#### 4.4.6 approval-labels +#### 4.4.6 trusted-users + +**Type**: Array of strings +**Required**: No +**Default**: Not specified (no users receive elevated integrity) + +The `trusted-users` field specifies GitHub usernames whose content items receive `approved` (writer) integrity level regardless of their `author_association`. This allows workflows to grant trusted access to specific external contributors without weakening the global `min-integrity` policy. + +**Semantics**: +- A content item authored by any user in `trusted-users` receives `approved` integrity, overriding the computed `author_association`-based integrity. +- `trusted-users` does NOT override `blocked-users`. An item authored by a user in both lists is still blocked. +- This field takes precedence over `approval-labels` — a trusted user's item is elevated without needing a label. +- Precedence order (highest to lowest): `blocked-users` > `trusted-users` > `approval-labels` > `author_association` + +**Use Cases**: +- Grant trusted access to known contractors or cross-org collaborators without adding them as repo collaborators. +- Allow specific service accounts or audit bots to have their content processed at `approved` integrity. +- Implement targeted trust elevation without lowering `min-integrity` globally. + +**Constraints**: +- Each entry MUST be a non-empty string. +- Matching is case-insensitive. +- Requires `min-integrity` to be set. +- An empty array `[]` is semantically equivalent to omitting the field and SHOULD be treated as such. +- Duplicate entries SHOULD generate a warning but are not errors. + +**Example**: +```yaml +trusted-users: + - "contractor-1" + - "partner-dev" +``` + +**Error Message** (empty array): +```text +Warning: trusted-users is an empty array. Omit the field entirely to have no trusted users. +``` + +#### 4.4.7 approval-labels **Type**: Array of strings **Required**: No @@ -792,6 +836,7 @@ The GitHub MCP server configuration combines tool selection (`toolsets` and `too **Integrity-Level Management** (this specification): - Controls **which content items** the agent may act upon based on their trust level - Unconditionally blocks items from listed authors (`blocked-users`) +- Elevates items from trusted authors to "approved" level (`trusted-users`) - Promotes items bearing designated labels to "approved" level (`approval-labels`) - Rejects items below the configured minimum trust threshold (`min-integrity`) @@ -805,6 +850,7 @@ tools: private-repos: false # And only public repositories min-integrity: "approved" # And only approved/merged content items blocked-users: ["external-bot"] # Never content from external-bot + trusted-users: ["contractor-1"] # contractor-1 always gets approved integrity approval-labels: ["human-reviewed"] # Label promotes to "approved" ``` @@ -818,7 +864,7 @@ tools: ### 4.6 Integrity Level Model -This section defines the integrity level hierarchy and the rules governing how `min-integrity`, `blocked-users`, and `approval-labels` interact. +This section defines the integrity level hierarchy and the rules governing how `min-integrity`, `blocked-users`, `trusted-users`, and `approval-labels` interact. #### 4.6.1 Integrity Hierarchy @@ -844,15 +890,17 @@ The effective integrity level of a content item is computed as follows: 1. Start with the item's base integrity level (computed from GitHub metadata). 2. IF the item's author is in blocked-users: effective_integrity ← blocked (terminates; item is always rejected) -3. ELSE IF any label on the item is in approval-labels: +3. ELSE IF the item's author is in trusted-users: + effective_integrity ← max(base_integrity, approved) +4. ELSE IF any label on the item is in approval-labels: effective_integrity ← max(base_integrity, approved) -4. ELSE: +5. ELSE: effective_integrity ← base_integrity ``` **Notes**: -- Step 2 takes precedence over step 3. Blocked users cannot be promoted by labels. -- Step 3 only raises the integrity level; it cannot lower it. An item already at `merged` stays at `merged`. +- Step 2 takes precedence over steps 3–4. Blocked users cannot be promoted by trusted-users or labels. +- Steps 3 and 4 only raise the integrity level; they cannot lower it. An item already at `merged` stays at `merged`. - The `max()` operation uses the ordinal values from the table above. #### 4.6.3 Access Decision