#!/bin/bash

## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
## See the file COPYING for copying conditions.

## AI-Assisted

## Apply project-wide GitHub security/policy settings to the source
## organizations (Kicksecure, Whonix). Idempotent and reconciling: a
## setting the policy wants OFF is actively turned off, not left as found.
## Run `dm-github-org-policy --help` for the full settings list.

set -o errexit
set -o nounset
set -o pipefail
set -o errtrace
shopt -s inherit_errexit
shopt -s shift_verbose

# shellcheck source=../libexec/developer-meta-files/github-org-lib.bsh
source "${DEVELOPER_META_FILES_PATH:-}"/usr/libexec/developer-meta-files/github-org-lib.bsh
# shellcheck source=../libexec/developer-meta-files/github-policy-lib.bsh
source "${DEVELOPER_META_FILES_PATH:-}"/usr/libexec/developer-meta-files/github-policy-lib.bsh
# shellcheck source=../libexec/developer-meta-files/github-policy-data.bsh
source "${DEVELOPER_META_FILES_PATH:-}"/usr/libexec/developer-meta-files/github-policy-data.bsh
## R-082: source every helper-scripts file used directly.
# shellcheck source=../../../helper-scripts/usr/libexec/helper-scripts/log_run_die.sh
source "${HELPER_SCRIPTS_PATH:-}"/usr/libexec/helper-scripts/log_run_die.sh
# shellcheck source=../../../helper-scripts/usr/libexec/helper-scripts/has.sh
source "${HELPER_SCRIPTS_PATH:-}"/usr/libexec/helper-scripts/has.sh
# shellcheck source=../../../helper-scripts/usr/libexec/helper-scripts/strings.bsh
source "${HELPER_SCRIPTS_PATH:-}"/usr/libexec/helper-scripts/strings.bsh

die_if_not_has github-org-fork

## ===== Per-org policy distinction =====
##
## SOURCE = canonical upstream org (Kicksecure, Whonix). Holds
##          originals; first-class bug-tracker.
##          Defaults: has_issues=true, has_projects=true,
##          has_discussions=true.
## MIRROR = fork org (org-ai-assisted) mirroring SOURCE, for
##          AI-assisted dev work. Issues / Projects / Discussions
##          belong on SOURCE. Defaults: has_issues=false,
##          has_projects=false, has_discussions=false.
## PROJECT = org holding one self-contained first-party project
##          (secure-terminal, output-lies). Not a copy of anything,
##          so issues and private vulnerability reporting live here.
##          Dependabot does not: it follows the CI, which runs on the
##          MIRROR. Defaults: has_issues=true, has_wiki=false,
##          has_discussions=false.
##
## Vocabulary aligns with dm-github-fork-sync.
readonly SOURCE_ORGS=( 'Kicksecure' 'Whonix' )
readonly MIRROR_ORGS=( 'org-ai-assisted' )
readonly PROJECT_ORGS=( 'secure-terminal' 'output-lies' )

## SOURCE_ORGS currently commented out pending an admin token scoped
## to those orgs. ORGS_OVERRIDE (comma-separated, since bash arrays
## cannot cross a process boundary as env vars) lets tests exercise
## the SOURCE pivot:
##   ORGS_OVERRIDE='Whonix' dm-github-org-policy --dry-run
# readonly ORGS=( "${SOURCE_ORGS[@]}" "${MIRROR_ORGS[@]}" )
if [ -n "${ORGS_OVERRIDE:-}" ]; then
   IFS=',' read -ra ORGS <<< "${ORGS_OVERRIDE}"
else
   ORGS=( "${MIRROR_ORGS[@]}" "${PROJECT_ORGS[@]}" )
fi
readonly ORGS

## Echo 'source' or 'mirror' for the named org; die on unknown. A
## typo'd org would otherwise silently get the wrong defaults.
org_kind() {
   local org name
   org="$1"
   for name in "${SOURCE_ORGS[@]}"; do
      if [ "${name}" = "${org}" ]; then
         printf '%s\n' source
         return 0
      fi
   done
   for name in "${MIRROR_ORGS[@]}"; do
      if [ "${name}" = "${org}" ]; then
         printf '%s\n' mirror
         return 0
      fi
   done
   for name in "${PROJECT_ORGS[@]}"; do
      if [ "${name}" = "${org}" ]; then
         printf '%s\n' project
         return 0
      fi
   done
   log error "org_kind: '${org}' not in SOURCE_ORGS, MIRROR_ORGS or PROJECT_ORGS"
   return 1
}

## Used at repo level (Free, applied per-repo) and at org level (PAID
## PLAN ONLY, commented out). Each name is unique within its scope;
## sharing makes a future plan upgrade trivial.
readonly RULESET_NAME_BRANCH='dm-github-org-policy default-branch protection'
readonly RULESET_NAME_TAG='dm-github-org-policy tag protection'
## PAID PLAN ONLY.
# readonly CODE_SEC_CONFIG_NAME='dm-github-org-policy code security'


dry_run=0
verbose=0
mode=''
mode_set=0
positional=()

show_help() {
   ## FIXME: Agent docs suggest private vulnerability reporting is actively
   ## disabled everywhere, but the help string here disagrees
   ##
   ## FIXME: Remove details about policy contents from help, they don't belong
   ## here.
   cat <<'EOF'
Apply project-wide GitHub security/policy settings to the source
organizations (Kicksecure, Whonix) and the mirror org
(org-ai-assisted). Idempotent and reconciling: every run also turns
OFF the settings the policy wants off (Dependabot everywhere except
the mirror org, private vulnerability reporting everywhere except the
project orgs).

Usage (one mode flag is required - no implicit default):
  dm-github-org-policy --apply         apply policy
  dm-github-org-policy --dry-run       report planned changes only
  dm-github-org-policy --audit         read-only inspection
  dm-github-org-policy --policy-dump   print the active policy table
                                       (cats github-policy-data.bsh)
  dm-github-org-policy --help

The source orgs are commented out of the ORGS array pending an admin
token, so their policy is DECLARATIVE: --audit measures it under
ORGS_OVERRIDE, --apply cannot reach it.

Run 'dm-github-org-policy --policy-dump' for the full table of what
gets applied (bodies, endpoints, methods, labels, SOURCE vs MIRROR
diffs).

Environment variable:
ORGS_OVERRIDE=Kicksecure,Whonix

Auth:
  ${GITHUB_TOKEN} env var, or chmod-600 ~/.config/github-token.
  Classic PAT needs admin:org + repo. Fine-grained PAT needs
  Administration r/w + Code security r/w + Webhooks r/w +
  Members r/w (the last for 2FA enforcement audit).
EOF
}

policy_tool_init 0 "$@"

## See agents/github-policy-org-kinds.md "Free-plan
## code-security replacements". Free-compatible per-repo endpoints
## used in lieu of the org-level configuration API:
##   - PUT /repos/{owner}/{repo}/vulnerability-alerts
##   - PUT /repos/{owner}/{repo}/automated-security-fixes
##   - PUT /repos/{owner}/{repo}/private-vulnerability-reporting
##   - PATCH /repos/{owner}/{repo} with security_and_analysis.{
##       secret_scanning, secret_scanning_push_protection
##     }.status = "enabled"
# apply_code_security_config() {
#    local org config_body existing_id list_body create_body
#    local attach_endpoint default_endpoint
#
#    org="$1"
#
#    config_body="$(ghorg_jq -n --arg name "${CODE_SEC_CONFIG_NAME}" -- \
#       "${POLICY_CODE_SECURITY} + {name: \$name}")"
#
#    if [ "${dry_run}" = '1' ]; then
#       log notice "DRY-RUN: ${org}: upsert code-security configuration ${CODE_SEC_CONFIG_NAME}"
#       log notice "DRY-RUN: ${org}: attach code-security configuration scope=all"
#       log notice "DRY-RUN: ${org}: set code-security configuration default_for_new_repos=all"
#       return 0
#    fi
#
#    policy_api_call "${org}: list code-security configurations" \
#       GET "/orgs/${org}/code-security/configurations?per_page=100" '' '' list_body \
#       || return 1
#
#    existing_id="$(printf '%s' "${list_body}" \
#       | ghorg_jq_capped -r --arg name "${CODE_SEC_CONFIG_NAME}" -- \
#         '.[] | select(.name == $name) | .id' \
#       | head --lines=1)"
#
#    if [ -n "${existing_id}" ] && [ "${existing_id}" != 'null' ]; then
#       if ! [[ "${existing_id}" =~ ^[0-9]+$ ]] || \
#          [ "${#existing_id}" -gt "${GHORG_MAX_ID_LEN}" ]; then
#          log warn "'${org}': code-security config id '${existing_id}' not a valid numeric id"
#          policy_warn_seen=1
#          return 1
#       fi
#       policy_api_call "${org}: update code-security configuration id=${existing_id}" \
#          PATCH "/orgs/${org}/code-security/configurations/${existing_id}" \
#          "${config_body}" || true
#    else
#       ## POST returns the new config object including its id, needed
#       ## for the attach + default calls.
#       policy_api_call "${org}: create code-security configuration" \
#          POST "/orgs/${org}/code-security/configurations" "${config_body}" '' create_body \
#          || return 1
#       existing_id="$(printf '%s' "${create_body}" | ghorg_jq_capped -r -- '.id')"
#       if ! [[ "${existing_id}" =~ ^[0-9]+$ ]] || \
#          [ "${#existing_id}" -gt "${GHORG_MAX_ID_LEN}" ]; then
#          log warn "'${org}': new code-security config id '${existing_id}' not a valid numeric id"
#          policy_warn_seen=1
#          return 1
#       fi
#       log notice "ok: ${org}: created code-security configuration id=${existing_id}"
#    fi
#
#    ## ATTACH and DEFAULTS endpoints embed the configuration id;
#    ## resolve __ID__ here, then __ORG__ via the dispatcher.
#    attach_endpoint="${POLICY_CODE_SECURITY_ATTACH_ENDPOINT_ORG//__ID__/${existing_id}}"
#    policy_api_call "${org}: ${POLICY_CODE_SECURITY_ATTACH_LABEL}" \
#       "${POLICY_CODE_SECURITY_ATTACH_METHOD}" \
#       "${attach_endpoint//__ORG__/${org}}" \
#       "$(ghorg_jq -n -- "${POLICY_CODE_SECURITY_ATTACH}")" \
#       || true
#
#    default_endpoint="${POLICY_CODE_SECURITY_DEFAULTS_ENDPOINT_ORG//__ID__/${existing_id}}"
#    policy_api_call "${org}: ${POLICY_CODE_SECURITY_DEFAULTS_LABEL}" \
#       "${POLICY_CODE_SECURITY_DEFAULTS_METHOD}" \
#       "${default_endpoint//__ORG__/${org}}" \
#       "$(ghorg_jq -n -- "${POLICY_CODE_SECURITY_DEFAULTS}")" \
#       || true
# }

## Org-level settings dispatch. Code-security configuration and the
## branch + tag rulesets are PAID PLAN ONLY (GHAS / GitHub Team+) and
## commented out with skip lines so a Free-org operator sees what was
## elided.
apply_org_policy() {
   local org kind

   org="$1"
   ## kind is also consumed by the UI-flip skip lines at
   ## the end of this function; the commented-out PAID-plan ruleset
   ## bypass-actor pivot below would also use it.
   kind="$(org_kind "${org}")" || return 1

   log notice "=== org policy: ${org} ==="

   policy_apply_org "${org}" FORK_PR_APPROVAL

   policy_apply_org "${org}" WORKFLOW_PERMS

   ## FIXME: The comment below includes "output-lies" in a partial list of
   ## MIRROR repos, when it is a PROJECT.
   ##
   ## Actions / CI scope pivots on org kind:
   ##   SOURCE (Kicksecure / Whonix): disable Actions entirely,
   ##     org-wide (enabled_repositories=none). The canonical SOURCE
   ##     repos run their own CI, not GitHub Actions; the AI-assisted
   ##     test + scanner CI runs on the MIRROR org. Disabling org-wide
   ##     also removes the fork-PR workflow-abuse surface and saves
   ##     Actions minutes. No allowlist follow-up: allowed_actions is
   ##     moot when Actions are off.
   ##   MIRROR (org-ai-assisted / output-lies): Actions on, restricted
   ##     to the github-owned + verified-creators allowlist - it is
   ##     where AI-assisted dev + CI run.
   ## See agents/github-policy-org-kinds.md.
   if [ "${kind}" = 'source' ]; then
      policy_apply_org "${org}" ACTIONS_DISABLED_ORG
   else
      policy_apply_org "${org}" ACTIONS_SCOPE
      policy_apply_org "${org}" ACTIONS_ALLOWLIST ENDPOINT
   fi

   policy_apply_org "${org}" MEMBERS

   policy_skip_ui "${org}" 2FA

   ## PAID PLAN ONLY: org-level code-security-configuration API
   ## requires GHAS, and org-level rulesets require GitHub Team+.
   ## Both fail with HTTP 400/403 on Free. Re-enable the four
   ## commented-out calls together.
   # apply_code_security_config "${org}" || true
   log notice "skip: ${org}: code-security configuration - PAID PLAN ONLY (GHAS); see github-policy-data.bsh POLICY_CODE_SECURITY"

   # bypass_var="POLICY_RULESET_BYPASS_${kind^^}"
   # if check_variable_name "${bypass_var}"; then
   #    policy_upsert_org_ruleset "${org}" "${RULESET_NAME_BRANCH}" \
   #       "$(policy_branch_ruleset_body "${RULESET_NAME_BRANCH}" org "${!bypass_var}")" \
   #       || true
   # fi

   # policy_upsert_org_ruleset "${org}" "${RULESET_NAME_TAG}" \
   #    "$(policy_tag_ruleset_body "${RULESET_NAME_TAG}" org)" \
   #    || true
   log notice "skip: ${org}: org-level branch + tag rulesets - PAID PLAN ONLY (GitHub Team+); per-repo rulesets work on Free for public repos"
   policy_skip_ui "${org}" PAT

   log notice "skip: ${org}: ${POLICY_APPS_LABEL} - see ${POLICY_APPS_UI_URL_INSTALLATIONS//__ORG__/${org}} and ${POLICY_APPS_UI_URL_OAUTH//__ORG__/${org}}"

   ## UI-flip notices for org-level Dependabot + Code-scanning settings
   ## with no documented REST setter as of 2026-05; see
   ## agents/github-policy-org-kinds.md "UI-only operator flips".
   ##
   ## Grouped security updates bundle SECURITY-UPDATE PULL REQUESTS, so
   ## the flip is only meaningful where those exist: MIRROR, the only kind
   ## that runs Dependabot at all.
   if [ "${kind}" = 'mirror' ]; then
      policy_skip_ui "${org}" GROUPED_SECURITY_UPDATES
   fi
   ## MIRROR runs CodeQL via the reusable codeql.yml workflow (advanced
   ## setup), which carries its own queries: value and ignores the
   ## default-setup recommendation.
   if [ "${kind}" = 'source' ]; then
      policy_skip_ui "${org}" CODE_SCANNING_QUERY_SUITE
   fi
}

## Count of audit findings: policy drift, plus any endpoint we could
## not read. An audit that cannot fail is a printer, not a check --
## and a run that could not reach the API must never read as clean.
## main() turns a non-zero count into a non-zero exit.
audit_findings=0

## Compare a live endpoint against the desired policy object and record
## drift. The desired value is the SAME POLICY_* literal that --apply
## PUTs, so the audit can never drift from what apply enforces.
##
## Only the keys the policy sets are compared: the API returns extra
## fields (urls, counts) that are not ours to own.
## Args: $1 = label, $2 = API path, $3 = desired JSON (jq object literal).
audit_expect() {
   local label path desired result status body drift line want_json drift_rc

   label="$1"
   path="$2"
   desired="$3"

   result="$(ghorg_api GET "${path}")" || {
      log warn "  ${label}: <api error> -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
      return 0
   }
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" != '200' ]; then
      log warn "  ${label}: HTTP ${status} -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
      return 0
   fi
   body="$(ghorg_body_of "${result}")"
   ## Render the desired object FIRST and on its own. Inline as a
   ## command argument, a failure here (a malformed POLICY_* literal)
   ## would expand to empty, jq would reject '--argjson want ""', and an
   ## empty ${drift} would be reported as "matches policy" -- a silent
   ## pass over an unverified org, the exact failure mode this whole
   ## change exists to remove.
   want_json="$(ghorg_jq -n -- "${desired}")" || want_json=''
   if [ -z "${want_json}" ]; then
      log warn "  ${label}: policy literal is not valid JSON -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
      return 0
   fi

   ## has()/getpath rather than '$live[.key] // null': jq's '//' yields
   ## its right-hand side when the left is null OR FALSE, so every policy
   ## value of `false` (can_approve_pull_request_reviews, for one) would
   ## read as absent and be reported as permanent drift.
   drift_rc=0
   drift="$(printf '%s' "${body}" \
      | ghorg_jq_capped --raw-output --argjson want "${want_json}" -- '
         . as $live
         | $want
         | to_entries[]
         | . as $e
         | select(($live | has($e.key) | not) or ($live[$e.key] != $e.value))
         | "\($e.key): live=\(if ($live | has($e.key)) then ($live[$e.key] | tojson) else "<absent>" end) want=\($e.value | tojson)"' \
      )" || drift_rc="$?"
   ## A comparison that could not RUN is not a comparison that passed.
   if [ "${drift_rc}" -ne 0 ]; then
      log warn "  ${label}: comparison failed (jq rc=${drift_rc}) -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
      return 0
   fi
   if [ -n "${drift}" ]; then
      log warn "  ${label}: DRIFT from policy"
      while IFS= read -r line; do
         [ -z "${line}" ] && continue
         log warn "    ${line}"
      done <<< "${drift}"
      audit_findings=$(( audit_findings + 1 ))
      return 0
   fi
   log notice "  ${label}: matches policy"
}

## Assert that a body-less on/off endpoint reads as ENABLED.
##
## GitHub answers "is this on?" for /vulnerability-alerts with HTTP 204
## (on) or 404 (off) and no body at all, so audit_expect's JSON key
## comparison does not apply. Only the must-be-ON direction is checkable
## here: a 404 is returned both when the feature is off and when the
## token cannot see it, so a must-be-OFF claim would be indistinguishable
## from a permissions wall and is deliberately not asserted through this
## endpoint.
## Args: $1 = label, $2 = API path.
audit_expect_enabled() {
   local label path result status

   label="$1"
   path="$2"

   result="$(ghorg_api GET "${path}")" || {
      log warn "  ${label}: <api error> -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
      return 0
   }
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '204' ]; then
      log notice "  ${label}: matches policy (enabled)"
      return 0
   fi
   if [ "${status}" = '404' ]; then
      log warn "  ${label}: HTTP 404 -- disabled, or invisible to this token -- NOT VERIFIED"
   else
      log warn "  ${label}: HTTP ${status} -- NOT VERIFIED"
   fi
   audit_findings=$(( audit_findings + 1 ))
}

## GET-and-print helper for audit_org_state.
## Args: $1 = label, $2 = API path, $3 = jq filter (default '.').
audit_get() {
   local label path jq_filter result status body extracted

   label="$1"
   path="$2"
   jq_filter="${3:-.}"

   result="$(ghorg_api GET "${path}")" || {
      log warn "  ${label}: <api error> -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
      return 0
   }
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" != '200' ]; then
      log warn "  ${label}: HTTP ${status} -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
      return 0
   fi
   body="$(ghorg_body_of "${result}")"
   extracted="$(printf '%s' "${body}" \
      | ghorg_jq_capped -r -- "${jq_filter}" 2>/dev/null \
      | tr -- '\n' ' ')"
   log notice "  ${label}: ${extracted:0:200}"
}

## Read-only inspection. Run before --apply to spot the change most
## likely to surprise: an Allowed-Actions tightening that breaks
## workflows pinned to unverified third-party actions.
audit_org_state() {
   local org result status body count names
   local actions_result actions_status actions_body
   local allowed_actions enabled_repos audit_kind
   local member
   local installs install_line total no_secret
   local repos repo missing_count have_count unreadable_count
   local private_names private_count

   org="$1"

   log notice "=== audit: ${org} ==="

   ## Every setting that has a POLICY_* literal is COMPARED, not merely
   ## printed. Printing was how an empty patterns_allowed sailed through:
   ## a human-readable summary line looks the same whether the value is
   ## right or wrong.
   audit_expect 'fork-PR approval policy' \
      "/orgs/${org}/actions/permissions/fork-pr-contributor-approval" \
      "${POLICY_FORK_PR_APPROVAL}"

   audit_expect 'workflow GITHUB_TOKEN permissions' \
      "/orgs/${org}/actions/permissions/workflow" \
      "${POLICY_WORKFLOW_PERMS}"

   ## /actions/permissions/selected-actions returns 409 when
   ## allowed_actions != "selected"; suppress the call in that case.
   allowed_actions=''
   actions_result="$(ghorg_api GET "/orgs/${org}/actions/permissions")" || true
   actions_status="$(ghorg_status_of "${actions_result}")"
   if [ "${actions_status}" = '200' ]; then
      actions_body="$(ghorg_body_of "${actions_result}")"
      ## Downstream comparison is exact-string against literals so a
      ## hostile-byte value falls into the N/A branch.
      allowed_actions="$(printf '%s' "${actions_body}" | ghorg_jq_capped --raw-output -- '.allowed_actions // "?"')"
      enabled_repos="$(printf '%s' "${actions_body}" | ghorg_jq_capped --raw-output -- '.enabled_repositories // "?"')"
      log notice "  actions allowed_actions: enabled_repos=${enabled_repos}, allowed_actions=${allowed_actions}"
   else
      log warn "  actions allowed_actions: HTTP ${actions_status} -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
   fi

   ## Expected scope depends on the org KIND, exactly as apply_org_policy
   ## chooses it: SOURCE orgs (Kicksecure / Whonix) have Actions disabled
   ## org-wide, everything else runs 'selected'. Comparing every org
   ## against POLICY_ACTIONS_SCOPE would report a correctly configured
   ## SOURCE org as drift and fail the audit on it.
   audit_kind="$(org_kind "${org}")" || audit_kind=''
   if [ "${audit_kind}" = 'source' ]; then
      audit_expect 'actions scope' \
         "/orgs/${org}/actions/permissions" \
         "${POLICY_ACTIONS_DISABLED_ORG}"
   elif [ -n "${audit_kind}" ]; then
      audit_expect 'actions scope' \
         "/orgs/${org}/actions/permissions" \
         "${POLICY_ACTIONS_SCOPE}"
   else
      log warn '  actions scope: org kind unknown -- NOT VERIFIED'
      audit_findings=$(( audit_findings + 1 ))
   fi

   ## /actions/permissions/selected-actions returns 409 when
   ## allowed_actions != "selected"; the scope check above already
   ## reported that as drift, so do not double-count it here.
   if [ "${allowed_actions}" = 'selected' ]; then
      ## Compared field-by-field against the policy, NOT summarised to a
      ## count: an empty patterns_allowed reads as "patterns=0" in a
      ## summary and looks like ordinary output, which is exactly how a
      ## policy that broke every cross-org reusable call passed audit.
      audit_expect 'selected-actions allow-list' \
         "/orgs/${org}/actions/permissions/selected-actions" \
         "${POLICY_ACTIONS_ALLOWLIST}"
   else
      log notice "  selected-actions allow-list: (N/A while allowed_actions=${allowed_actions:-?})"
   fi

   audit_get '2FA required for org members' \
      "/orgs/${org}" \
      '.two_factor_requirement_enabled // false'

   audit_get 'code-security defaults for new repos' \
      "/orgs/${org}" \
      '"secret=" + ((.secret_scanning_enabled_for_new_repositories // false) | tostring) + ", push_prot=" + ((.secret_scanning_push_protection_enabled_for_new_repositories // false) | tostring) + ", dep_alerts=" + ((.dependabot_alerts_enabled_for_new_repositories // false) | tostring) + ", dep_updates=" + ((.dependabot_security_updates_enabled_for_new_repositories // false) | tostring) + ", dep_graph=" + ((.dependency_graph_enabled_for_new_repositories // false) | tostring)'

   ## Members without 2FA - the list that would be removed if 2FA
   ## enforcement were enabled.
   log notice '  members lacking 2FA (would lose org access until they enroll):'
   result="$(ghorg_api GET "/orgs/${org}/members?filter=2fa_disabled&per_page=100")" || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      body="$(ghorg_body_of "${result}")"
      count="$(printf '%s' "${body}" | ghorg_jq_capped -- 'length')"
      if [ "${count}" = '0' ]; then
         log notice '    (none)'
      else
         names="$(printf '%s' "${body}" | ghorg_jq_capped -r -- '.[].login')"
         while IFS= read -r member; do
            [ -z "${member}" ] && continue
            log notice "    - ${member}"
         done <<< "${names}"
      fi
   else
      ## An endpoint we could not read is a finding: the audit's
      ## contract is that anything unverified fails, not that it is
      ## quietly assumed fine.
      log warn "    HTTP ${status} (need admin:org / Members read scope) -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
   fi

   ## PAID PLAN ONLY. Org-level rulesets list (GET /orgs/{org}/rulesets)
   ## returns HTTP 403 with body "Upgrade to GitHub Team to enable this
   ## feature." on Free-plan orgs. The org-ai-assisted org is Free, so
   ## this block was dead noise on every audit. Apply uses repo-level
   ## rulesets (Free-compatible) via policy_upsert_repo_ruleset; this
   ## listing block is only useful if the plan is upgraded to Team+.
   ## Uncomment if/when the plan changes; restore the corresponding
   ## 'existing rulesets named ...' assertions in
   ## ci/tests/test_dm_audit.sh too.
   # result="$(ghorg_api GET "/orgs/${org}/rulesets?per_page=100")" || true
   # status="$(ghorg_status_of "${result}")"
   # if [ "${status}" = '200' ]; then
   #    ruleset_body="$(ghorg_body_of "${result}")"
   #    for ruleset_name in "${RULESET_NAME_BRANCH}" "${RULESET_NAME_TAG}"; do
   #       log notice "  existing rulesets named \"${ruleset_name}\":"
   #       ids="$(printf '%s' "${ruleset_body}" \
   #          | ghorg_jq_capped -r --arg name "${ruleset_name}" -- \
   #            '.[] | select(.name == $name) | .id' || true)"
   #       if [ -z "${ids}" ]; then
   #          log notice '    (none; --apply would create one)'
   #       else
   #          while IFS= read -r id; do
   #             [ -z "${id}" ] && continue
   #             log notice "    - id=${id}"
   #          done <<< "${ids}"
   #       fi
   #    done
   # else
   #    log notice "  rulesets list: HTTP ${status} (paid plan only / token may lack admin:org)"
   # fi

   ## GITHUB-APP-ONLY. Both endpoints under 'fine-grained PAT activity'
   ## are GitHub-App-only per docs.github.com:
   ##   GET /orgs/{org}/personal-access-token-requests
   ##   GET /orgs/{org}/personal-access-tokens
   ## "Only GitHub Apps can use this endpoint." Classic and fine-grained
   ## PATs are rejected regardless of scope - response is HTTP 404 with
   ## empty 'x-accepted-oauth-scopes' header. The PAT-restriction policy
   ## toggle in the org UI does NOT change this: it controls whether
   ## PATs may BE USED to access org resources, not whether REST clients
   ## can ENUMERATE such tokens. dm-github-org-policy is entirely
   ## PAT-authenticated via ghorg_api, so these calls can never return
   ## data here - block was dead noise on every audit.
   ## Uncomment if/when an App-token code path is added; restore the
   ## 'fine-grained PAT activity:' assertion in
   ## ci/tests/test_dm_audit.sh too.
   # log notice '  fine-grained PAT activity:'
   # result="$(ghorg_api GET "/orgs/${org}/personal-access-token-requests?per_page=1")" || true
   # status="$(ghorg_status_of "${result}")"
   # case "${status}" in
   #    200)
   #       pending="$(ghorg_body_of "${result}" | ghorg_jq_capped -- 'length' 2>/dev/null || printf '%s' '?')"
   #       log notice "    pending requests on first page: ${pending}"
   #       ;;
   #    404|403)
   #       log notice "    requests endpoint: HTTP ${status} (endpoint accepts GitHub App tokens only, not PATs)"
   #       ;;
   #    *)
   #       log notice "    requests endpoint: HTTP ${status}"
   #       ;;
   # esac
   # result="$(ghorg_api GET "/orgs/${org}/personal-access-tokens?per_page=1")" || true
   # status="$(ghorg_status_of "${result}")"
   # case "${status}" in
   #    200)
   #       approved="$(ghorg_body_of "${result}" | ghorg_jq_capped -- 'length' 2>/dev/null || printf '%s' '?')"
   #       log notice "    approved tokens on first page: ${approved}"
   #       ;;
   #    *)
   #       log notice "    approved tokens endpoint: HTTP ${status} (endpoint accepts GitHub App tokens only, not PATs)"
   #       ;;
   # esac

   log notice '  installed GitHub Apps:'
   result="$(ghorg_api GET "/orgs/${org}/installations?per_page=100")" || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      installs="$(ghorg_body_of "${result}" \
         | ghorg_jq_capped -r -- '.installations[]? | "    - " + (.app_slug // "?") + " (id=" + ((.id // 0) | tostring) + ")"' \
         2>/dev/null || true)"
      if [ -z "${installs}" ]; then
         log notice '    (none)'
      else
         while IFS= read -r install_line; do
            [ -z "${install_line}" ] && continue
            log notice "${install_line}"
         done <<< "${installs}"
      fi
   else
      log warn "    HTTP ${status} -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
   fi

   ## Org webhooks: any without a configured `secret` are a Critical
   ## risk per the OSSF Scorecard "Webhooks" check. The GitHub API
   ## never returns the secret value itself; presence is reflected as
   ## a non-empty placeholder in `.config.secret`. Absent or empty
   ## means no secret configured - the webhook accepts unauthenticated
   ## payloads.
   log notice '  org webhooks (Scorecard "Webhooks" check):'
   result="$(ghorg_api GET "/orgs/${org}/hooks?per_page=100")" || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      total="$(ghorg_body_of "${result}" | ghorg_jq_capped -- 'length' 2>/dev/null || printf '%s' '?')"
      no_secret="$(ghorg_body_of "${result}" \
         | ghorg_jq_capped -- '[.[] | select(.config.secret == null or .config.secret == "")] | length' \
         2>/dev/null || printf '%s' '?')"
      if [ "${total}" = '0' ]; then
         log notice '    (none configured)'
      else
         log notice "    total=${total}, lacking secret=${no_secret}"
      fi
   else
      log warn "    HTTP ${status} (token may lack admin:org_hook scope) -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
   fi

   ## Private (non-archived) repos. None should exist in the active
   ## layout. A private repo on Free loses secret scanning + push
   ## protection (those features need GHAS on private repos).
   log notice '  private repos (would lose secret-scan + push-protection on Free without GHAS):'
   result="$(ghorg_api GET "/orgs/${org}/repos?type=private&per_page=100")" || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      body="$(ghorg_body_of "${result}")"
      ## ghorg_mock_dispatch strips queries before fixture lookup, so
      ## an explicit .private filter keeps real and mock paths
      ## consistent against a mixed-list fixture.
      private_names="$(printf '%s' "${body}" \
         | ghorg_jq_capped -r -- '.[] | select(.private == true and .archived == false) | .name')"
      private_count="$(printf '%s' "${private_names}" | grep --count -- . || true)"
      if [ "${private_count}" = '0' ]; then
         log notice '    (none)'
      else
         while IFS= read -r repo; do
            [ -z "${repo}" ] && continue
            log notice "    - ${org}/${repo}"
         done <<< "${private_names}"
      fi
   else
      log warn "    HTTP ${status} (token may lack repo scope to enumerate private repos) -- NOT VERIFIED"
      audit_findings=$(( audit_findings + 1 ))
   fi

   ## OSSF Scorecard "Dependency-Update-Tool" looks for
   ## .github/dependabot.yml file presence specifically, so even with
   ## org-level Dependabot alerts enabled, repos missing the file
   ## still fail Scorecard.
   ##
   ## Off the mirror the same probe is a POLICY CHECK, not an inventory
   ## line: the file IS the version-update switch and only MIRROR may
   ## carry one, because only MIRROR runs the CI that verifies the bump
   ## PRs it opens. On MIRROR the file is opt-in per repo (see
   ## dm-packaging-helper-script), so either answer is in policy there.
   ## Archived repos are outside this enumeration; Dependabot does not
   ## run on them, so a leftover config there is inert until the repo is
   ## un-archived.
   log notice '  dependabot.yml presence (Scorecard "Dependency-Update-Tool"):'
   repos="$(ghorg_list_repos "${org}" 'false' 'false' 'true' | sort --unique)"
   if [ -z "${repos}" ]; then
      log notice '    (no public non-archived repos)'
   else
      ## Per-repo presence is shown so legitimately-bare repos
      ## (no language deps to track - propagating dependabot.yml
      ## everywhere just to satisfy Scorecard would be noise) are
      ## visible alongside the ones that genuinely need it.
      missing_count=0
      have_count=0
      unreadable_count=0
      while IFS= read -r repo; do
         [ -z "${repo}" ] && continue
         result="$(ghorg_api GET "/repos/${org}/${repo}/contents/${POLICY_DEPENDABOT_VERSION_UPDATES_OFF_PATH}")" || true
         status="$(ghorg_status_of "${result}")"
         case "${status}" in
            200)
               have_count=$(( have_count + 1 ))
               log notice "    yes: ${org}/${repo}"
               if [ "${audit_kind}" != 'mirror' ]; then
                  log warn "    ${org}/${repo}: DRIFT from policy: Dependabot version updates are ON (${POLICY_DEPENDABOT_VERSION_UPDATES_OFF_PATH} present); only the mirror org has CI to verify the PRs they open"
                  audit_findings=$(( audit_findings + 1 ))
               fi
               ;;
            404)
               missing_count=$(( missing_count + 1 ))
               log notice "    no:  ${org}/${repo}"
               ;;
            *)
               ## Neither a hit nor a clean 404 is an UNKNOWN, not an
               ## absence. Counted with the missing ones so the inventory
               ## summary keeps one meaning, but reported as a finding on
               ## every kind: printing "no:" for a probe that never
               ## answered is exactly the false green this audit exists to
               ## prevent, and it is equally false on the mirror.
               unreadable_count=$(( unreadable_count + 1 ))
               missing_count=$(( missing_count + 1 ))
               log notice "    no:  ${org}/${repo}"
               log warn "    ${org}/${repo}: HTTP ${status} probing ${POLICY_DEPENDABOT_VERSION_UPDATES_OFF_PATH} -- NOT VERIFIED"
               audit_findings=$(( audit_findings + 1 ))
               ;;
         esac

         ## Second Dependabot switch: SECURITY updates, the one with a
         ## REST endpoint. Compared against the same literal the apply
         ## path enforces, per kind: ON on the mirror, OFF everywhere
         ## else.
         ##
         ## Alerts are asserted only in the must-be-ON direction, on
         ## MIRROR. GET /vulnerability-alerts answers 404 both when
         ## alerts are off and when the token cannot see the repo, so a
         ## must-be-OFF claim through it would be indistinguishable from a
         ## permissions wall -- see agents/github-policy-org-kinds.md,
         ## "What --audit compares".
         if [ "${audit_kind}" = 'mirror' ]; then
            audit_expect "dependabot security updates ${org}/${repo}" \
               "/repos/${org}/${repo}/automated-security-fixes" \
               "${POLICY_REPO_DEPENDABOT_FIXES_EXPECT_ON}"
            audit_expect_enabled "dependabot alerts ${org}/${repo}" \
               "/repos/${org}/${repo}/vulnerability-alerts"
         elif [ -n "${audit_kind}" ]; then
            audit_expect "dependabot security updates ${org}/${repo}" \
               "/repos/${org}/${repo}/automated-security-fixes" \
               "${POLICY_REPO_DEPENDABOT_FIXES_EXPECT_OFF}"
         fi
      done <<< "${repos}"
      log notice "    summary: have=${have_count}, missing=${missing_count}"
      if [ "${unreadable_count}" -gt 0 ]; then
         log notice "    note: ${unreadable_count} repo(s) answered neither 200 nor 404; counted as missing above"
      fi
   fi
}

## Per-repo settings. Steps + the SOURCE-vs-MIRROR rationale are
## documented once in agents/github-policy-org-kinds.md;
## the constants this function reads (POLICY_REPO_*, POLICY_RULESET_
## BYPASS_*, POLICY_RULESET_RULES_*) are defined in
## github-policy-data.bsh.
apply_repo_policy() {
   local owner repo kind bypass_var rules_var

   owner="$1"
   repo="$2"

   ghorg_validate_name "${owner}" user || return 1
   ghorg_validate_name "${repo}"  repo || return 1

   kind="$(org_kind "${owner}")" || return 1
   policy_apply_repo "${owner}" "${repo}" "REPO_${kind^^}" ENDPOINT

   # Order: G-035 / see github-policy-org-kinds.md
   ## Dependabot runs on MIRROR and nowhere else. SOURCE has Actions disabled org-wide
   ## (ACTIONS_DISABLED_ORG above), so a bump PR raised there can never carry a CI
   ## verdict. PROJECT repos are mirrored into org-ai-assisted, where the AI-assisted
   ## test + scanner suites run, so running Dependabot on both sides only splits one
   ## alert inbox and one bump stream in two. Everywhere except MIRROR both switches are
   ## actively DELETEd every --apply, so a leftover from an older run or a UI flip is
   ## reconciled away. Order: FIXES before ALERTS on the disable side - the
   ## security-fixes endpoint answers 422 once alerts are off, the idempotent steady
   ## state; reversed on the enable side, where the PUT requires alerts first.
   ## See agents/github-policy-org-kinds.md.
   if [ "${kind}" = 'mirror' ]; then
      policy_apply_repo "${owner}" "${repo}" REPO_DEPENDABOT_ALERTS ENDPOINT
      policy_apply_repo "${owner}" "${repo}" REPO_DEPENDABOT_FIXES  ENDPOINT
   else
      policy_apply_repo "${owner}" "${repo}" REPO_DEPENDABOT_FIXES_OFF  ENDPOINT
      policy_apply_repo "${owner}" "${repo}" REPO_DEPENDABOT_ALERTS_OFF ENDPOINT
   fi

   ## PVR is disabled on EVERY kind, PROJECT included. We do not use it:
   ## security reports come in as OpenPGP-encrypted e-mail, and that is
   ## the only channel we watch. Leaving GitHub's private-report inbox
   ## open on any repo would create a second one nobody reads -- worse
   ## than no channel, because a researcher who finds it reasonably
   ## assumes it is monitored.
   policy_apply_repo "${owner}" "${repo}" REPO_PVR_OFF ENDPOINT

   bypass_var="POLICY_RULESET_BYPASS_${kind^^}"
   rules_var="POLICY_RULESET_RULES_${kind^^}"
   if check_variable_name "${bypass_var}" && check_variable_name "${rules_var}"; then
      policy_upsert_repo_ruleset "${owner}" "${repo}" "${RULESET_NAME_BRANCH}" \
         "$(policy_branch_ruleset_body "${RULESET_NAME_BRANCH}" repo "${!bypass_var}" "${!rules_var}")" \
         || true
      policy_upsert_repo_ruleset "${owner}" "${repo}" "${RULESET_NAME_TAG}" \
         "$(policy_tag_ruleset_body "${RULESET_NAME_TAG}" repo "${!bypass_var}" "${!rules_var}")" \
         || true
   fi

   ## UI-flip notices for per-repo Dependabot ALERT settings with no
   ## documented REST setter as of 2026-05; see
   ## agents/github-policy-org-kinds.md "UI-only operator flips".
   ## Emitted where alerts exist to be triaged or dismissed - MIRROR only,
   ## the one kind that runs Dependabot.
   if [ "${kind}" = 'mirror' ]; then
      policy_skip_ui_repo "${owner}" "${repo}" AUTO_TRIAGE_DISMISS_DEV
      policy_skip_ui_repo "${owner}" "${repo}" AUTO_TRIAGE_DISMISS_MALWARE
      policy_skip_ui_repo "${owner}" "${repo}" DELEGATED_DISMISSAL
   else
      ## Dependabot VERSION updates have no REST setter at any scope: the
      ## presence of .github/dependabot.yml is the switch, so turning them
      ## off means a commit that deletes the file. Emitted per repo
      ## because that is the granularity the fix has.
      policy_skip_ui_repo "${owner}" "${repo}" DEPENDABOT_VERSION_UPDATES_OFF
   fi
}

main() {
   local org repos repo
   if [ "${mode}" = 'audit' ]; then
      for org in "${ORGS[@]}"; do
         audit_org_state "${org}"
      done
      ## Exit non-zero on drift or on any endpoint that could not be
      ## read. Without this the audit reported success no matter what it
      ## found -- including a total API failure -- so a scheduled lane
      ## would have shown green over a broken org.
      if [ "${audit_findings}" -gt 0 ]; then
         log error "audit: ${audit_findings} finding(s) -- live state does not match policy"
         return 1
      fi
      ## Deliberately narrow wording. Every setting backed by a POLICY_*
      ## literal is compared; the remaining lines (2FA, code-security
      ## defaults for new repos, member/app/webhook inventories) are
      ## reported for a human to read and have no desired-state literal
      ## to compare against. Claiming the whole live policy matches would
      ## overstate what ran.
      log notice 'audit: every enforced setting matches policy; no endpoint went unread'
      return 0
   fi

   for org in "${ORGS[@]}"; do
      apply_org_policy "${org}"

      log notice "=== repo policy: ${org} ==="
      ## Public, non-archived repos (forks INCLUDED). The
      ## org-ai-assisted mirror holds 100% forks of upstream
      ## Kicksecure / Whonix repos; excluding forks would silently
      ## no-op the entire per-repo phase against that org.
      repos="$(ghorg_list_repos "${org}" 'false' 'false' 'true' | sort --unique)"
      if [ -z "${repos}" ]; then
         log notice "no repos matched in '${org}'"
         continue
      fi

      ## Sequential loop: a small org (~12 repos) is fast enough not
      ## to need backgrounding, and lets policy_warn_seen mutations
      ## from the lib propagate back without a tempfile / pid dance.
      while IFS= read -r repo; do
         [ -z "${repo}" ] && continue
         apply_repo_policy "${org}" "${repo}"
      done <<< "${repos}"
   done

   [ "${policy_warn_seen}" -eq 1 ] && return 1
   return 0
}

main
