Protect (PR) · Approval gating
Approval gating at the server side layer
external · Outside the agent entirely
If the agent decides to violate this concern, what stops it at this layer?
What this cell does
Branch protection + PR review, CODEOWNERS, multi-party prod approval, plan-and-apply split, deployment freezes.
Artifacts (4)
CODEOWNERSview on GitHub# ABOUTME: Template CODEOWNERS. Substitute @your-org/team-name placeholders. Place at .github/CODEOWNERS or repo root.
# Default owner for everything not otherwise covered.
* @your-org/platform-team
# Infrastructure-as-code is owned by platform.
/infrastructure/ @your-org/platform-team @your-org/security-team
/terraform/ @your-org/platform-team @your-org/security-team
# Production-specific changes need senior platform engineers.
/infrastructure/prod/ @your-org/platform-leads @your-org/security-team
/k8s/prod/ @your-org/platform-leads @your-org/security-team
# CI configuration is supply-chain-adjacent; security-team review required.
/.github/workflows/ @your-org/security-team @your-org/platform-leads
/.github/CODEOWNERS @your-org/security-leads
/.github/branch-protection-expected.json @your-org/security-leads
# Secrets, key material, sealed secrets.
/secrets/ @your-org/security-leads
*.tfvars @your-org/security-team
**/*.encrypted @your-org/security-team
**/*.sops.yaml @your-org/security-team
# Agent runtime configuration. The agent must NOT be a CODEOWNER for these.
/.claude/ @your-org/platform-team @your-org/security-team
/etc/agents/ @your-org/platform-team @your-org/security-team
/.mcp.json @your-org/security-team
# Database migrations and schema.
/db/migrations/ @your-org/platform-leads @your-org/data-platform
/schema/ @your-org/data-platform
# Public-facing API contracts. Both backend and SRE need to sign off.
/api/ @your-org/api-team @your-org/sre-team
/openapi.yaml @your-org/api-team
apply-branch-protection.shview on GitHub#!/usr/bin/env bash
# ABOUTME: Applies the desired branch-protection state via gh api. Asserts enforce_admins=true and refuses without it.
# ABOUTME: Run once per repo. UI clicks are not auditable; this script is.
set -euo pipefail
REPO="${1:-}"
BRANCH="${2:-main}"
EXPECTED_JSON="${3:-$(dirname "$0")/branch-protection-expected.json}"
if [[ -z "$REPO" ]]; then
cat <<'USAGE' >&2
Usage: apply-branch-protection.sh <owner/repo> [branch] [expected-json-path]
Example:
apply-branch-protection.sh peopleforrester/agentic-covenants main ./branch-protection-expected.json
The script reads the expected JSON, asserts enforce_admins=true (refuses
otherwise), and applies the configuration via gh api. After applying it
re-reads the live state and diffs it against the expected JSON.
USAGE
exit 64
fi
if [[ ! -r "$EXPECTED_JSON" ]]; then
echo "Cannot read expected JSON at $EXPECTED_JSON" >&2
exit 1
fi
ENFORCE_ADMINS="$(jq -r '.enforce_admins' "$EXPECTED_JSON")"
if [[ "$ENFORCE_ADMINS" != "true" ]]; then
echo "REFUSING: expected JSON has enforce_admins=$ENFORCE_ADMINS." >&2
echo "enforce_admins=true is the load-bearing flag for branch protection." >&2
echo "Edit $EXPECTED_JSON to set it true before applying." >&2
exit 1
fi
REQ_REVIEWERS="$(jq -r '.required_pull_request_reviews.required_approving_review_count // 0' "$EXPECTED_JSON")"
if [[ "$REQ_REVIEWERS" -lt 2 ]]; then
echo "REFUSING: required_approving_review_count=$REQ_REVIEWERS." >&2
echo "One reviewer plus the agent operating the keyboard equals zero adversarial review." >&2
echo "Set required_approving_review_count to at least 2." >&2
exit 1
fi
echo "Applying branch protection on $REPO@$BRANCH from $EXPECTED_JSON ..."
# gh api PUT on the protection endpoint accepts the full JSON body.
gh api -X PUT \
"repos/$REPO/branches/$BRANCH/protection" \
--input "$EXPECTED_JSON"
echo "Applied. Re-reading live state for verification..."
LIVE="$(gh api "repos/$REPO/branches/$BRANCH/protection")"
# Verify a small set of critical fields.
LIVE_ENFORCE_ADMINS="$(echo "$LIVE" | jq -r '.enforce_admins.enabled')"
LIVE_REQ_REVIEWERS="$(echo "$LIVE" | jq -r '.required_pull_request_reviews.required_approving_review_count')"
LIVE_CODEOWNERS="$(echo "$LIVE" | jq -r '.required_pull_request_reviews.require_code_owner_reviews')"
PASS=0
FAIL=0
check() { if [[ "$2" == "$3" ]]; then echo "PASS: $1 = $2"; PASS=$((PASS+1)); else echo "FAIL: $1: expected $3, got $2"; FAIL=$((FAIL+1)); fi; }
check "enforce_admins.enabled" "$LIVE_ENFORCE_ADMINS" "true"
check "required_pull_request_reviews.required_approving_review_count" "$LIVE_REQ_REVIEWERS" "$REQ_REVIEWERS"
check "required_pull_request_reviews.require_code_owner_reviews" "$LIVE_CODEOWNERS" "true"
echo ""
echo "Summary: $PASS passed, $FAIL failed"
exit "$FAIL"
branch-protection-expected.jsonview on GitHub{
"required_status_checks": {
"strict": true,
"contexts": [
"ci/build",
"ci/test",
"ci/security",
"lockfile-integrity"
]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 2,
"dismiss_stale_reviews": true,
"require_code_owner_reviews": true,
"require_last_push_approval": true,
"bypass_pull_request_allowances": null
},
"required_linear_history": true,
"allow_force_pushes": false,
"allow_deletions": false,
"block_creations": false,
"required_conversation_resolution": true,
"lock_branch": false,
"allow_fork_syncing": false,
"restrictions": {
"users": [],
"teams": ["release-managers"],
"apps": []
}
}
freeze.ymlview on GitHub# ABOUTME: Workflow that polls PagerDuty for active incidents and sets the DEPLOY_FREEZE repo variable.
# ABOUTME: The IaC pipeline reads vars.DEPLOY_FREEZE and refuses to apply when true. Drop in .github/workflows/.
name: Deployment Freeze
on:
schedule:
# Poll every minute. PagerDuty's free tier rate-limit easily accommodates 1/min.
- cron: '* * * * *'
workflow_dispatch:
inputs:
override:
description: "Manual override: 'freeze' or 'unfreeze'"
required: true
default: 'unfreeze'
permissions:
contents: read
actions: write # to set repo variables
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Manual override
id: override
if: github.event_name == 'workflow_dispatch'
run: |
case "${{ inputs.override }}" in
freeze) echo "value=true" >> $GITHUB_OUTPUT ;;
unfreeze) echo "value=false" >> $GITHUB_OUTPUT ;;
*) echo "::error::override must be 'freeze' or 'unfreeze'"; exit 1 ;;
esac
- name: Check PagerDuty for active incidents
id: pd
if: github.event_name == 'schedule'
env:
PD_TOKEN: ${{ secrets.PAGERDUTY_TOKEN }}
PD_SERVICE_ID: ${{ secrets.PAGERDUTY_SERVICE_ID }}
run: |
ACTIVE=$(curl -sS -H "Authorization: Token token=$PD_TOKEN" \
-H "Accept: application/vnd.pagerduty+json;version=2" \
"https://api.pagerduty.com/incidents?statuses[]=triggered&statuses[]=acknowledged&service_ids[]=$PD_SERVICE_ID" \
| jq '.incidents | length')
if [[ "$ACTIVE" -gt 0 ]]; then
echo "value=true" >> $GITHUB_OUTPUT
else
echo "value=false" >> $GITHUB_OUTPUT
fi
- name: Set DEPLOY_FREEZE repo variable
env:
GH_TOKEN: ${{ secrets.PLATFORM_PAT }}
run: |
DESIRED="${{ steps.override.outputs.value || steps.pd.outputs.value }}"
if [[ -z "$DESIRED" ]]; then
echo "::error::No DESIRED value computed (this should not happen)"
exit 1
fi
CURRENT="$(gh variable get DEPLOY_FREEZE -R "${{ github.repository }}" 2>/dev/null || echo unset)"
if [[ "$CURRENT" == "$DESIRED" ]]; then
echo "DEPLOY_FREEZE already $DESIRED; no change."
else
gh variable set DEPLOY_FREEZE -b "$DESIRED" -R "${{ github.repository }}"
echo "DEPLOY_FREEZE: $CURRENT -> $DESIRED"
fi
Cell notes
Approval gating / Server-side
Control. Branch protection requiring PR review. CODEOWNERS on critical paths. Multi-party approval on prod merges. IaC pipeline runs plan only; separate gated job runs apply. Deployment freezes during incident windows enforced by pipeline, not policy. "Do not allow bypassing the above settings" must be enabled.
Strength. Deterministic. Bypass requires admin-override (which enforce_admins=true blocks), collusion between two reviewers, social-engineering through a persuasive PR description, or paths not covered by CODEOWNERS.
Tooling
- - GitHub branch protection rules (or GitLab protected branches, or Bitbucket equivalents).
- - A CODEOWNERS file in
.github/CODEOWNERS(ordocs/CODEOWNERS, orCODEOWNERSat root). - - GitHub environments with required reviewers (configured against the IaC pipeline's
applyjob from../../blast-radius/server-side/iac-gated-pipeline.yml). - - A deployment-freeze automation: PagerDuty event hook or a cron-driven config flip.
Files in this directory
- -
apply-branch-protection.sh, wraps thegh apicall that sets every branch-protection field correctly. Run once per repo. The script assertsenforce_admins=trueand refuses to apply without it. - -
branch-protection-expected.json, declarative target state for branch protection. The detection job in../../../sentinels/approval-gating/server-side/compares this to the live state and alerts on drift. - -
CODEOWNERS, template CODEOWNERS file. Drop at.github/CODEOWNERSand substitute team names. - -
freeze.yml, workflow that polls PagerDuty every minute and sets theDEPLOY_FREEZErepo variable. Theapplyjob in the IaC pipeline reads the variable and refuses to run when true.
Verification
# 1. Confirm enforce_admins (the load-bearing flag)
gh api repos/peopleforrester/agentic-covenants/branches/main/protection \
--jq '.enforce_admins.enabled'
# expected: true
# 2. Confirm required reviews and CODEOWNERS
gh api repos/peopleforrester/agentic-covenants/branches/main/protection/required_pull_request_reviews \
--jq '{count: .required_approving_review_count, codeowners: .require_code_owner_reviews}'
# expected: count >= 2, codeowners: true
# 3. Confirm bypass is not allowed
# Open a PR as an admin without approvals and try to merge:
gh pr merge --admin
# expected: failure ("required pull request review")
# 4. Confirm freeze actually freezes
gh variable set DEPLOY_FREEZE -b true
# (next apply run skips with "Deployment freeze active")
gh variable set DEPLOY_FREEZE -b false
Common mistakes
- -
enforce_admins: false(the GitHub default historically). Anyone with admin role on the repo walks through every other rule. The most common branch-protection failure. - - CODEOWNERS file in the wrong path. Must be at
.github/CODEOWNERS,docs/CODEOWNERS, orCODEOWNERSat root. Anywhere else is treated as data, not code-ownership. - - Required reviewers count of 1. One reviewer plus the agent operating the keyboard equals zero adversarial review.
- - Freeze controlled by a UI toggle that the on-call engineer can flip. The freeze should require a PR to disable.
- - Required status checks named in protection but never wired up in CI; the rule passes vacuously because the check never runs.
Citation
NIST CSF 2.0 PR.AA-05 (least privilege, separation of duties), PR.PS-01, GV.RR-02. NIST AI RMF GOVERN 4.1 (organizational practices supporting AI risk management), MANAGE 4.1. OWASP LLM06. OWASP ASI02, ASI03. EU AI Act Art. 14 (human oversight), Art. 26 (deployer obligations). ISO/IEC 42001 §A.4. NIST SP 800-160 Vol. 1 (separation of duties as a security principle).
Primary bypasses
Documented, not hypothetical. A control whose bypass is undocumented is worse than no control, because somebody trusted it.
- persuasive PR description influencing reviewers
- admin force-push override
- bot accounts approving via misconfigured CODEOWNERS
Crosswalk
| NIST CSF 2 0 | PR.AA-05, PR.PS-01, GV.RR-02, GV.SC-04 |
|---|---|
| NIST AI RMF | GOVERN 4.1, MANAGE 4.1 |
| OWASP LLM | LLM06 |
| OWASP AGENTIC | ASI02, ASI03 |
| OTHER | NIST SP 800-160 Vol. 1 |
Cite this cell:
https://agenticcovenants.com/protect/approval-gating/server-side/