From 4a28c3ab6b397dc711963cc794e4b6ba43327d75 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sat, 18 Jul 2026 09:48:37 -0400 Subject: [PATCH 1/4] FIX: resolve real tool binary to prevent sandbox PATH shadowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add resolve_real_tool_binary() to lib/bwrap_sandbox_lib.sh. On managed hosts the dotfiles wrapper (~/.local/bin/opencode) shadows the real npm binary on PATH. command -v opencode then resolves to the wrapper; its directory gets added to SANDBOX_PATH and bind-mounted into the --clearenv sandbox, where AZURE_*/N2SNSCRIPTS_LIB are stripped and the wrapper fails with 'cannot locate gpg-passwd.sh' (the double-invocation bug). Fix: detect dotfiles wrappers by checking whether readlink -f resolves to a path containing /config/scripts/. If so, substitute the npm-prefix binary directly. Hard-error if the npm binary is also absent (returning the wrapper would silently recreate the bug). Non-npm tools (system packages) are unaffected — the candidate is returned unchanged. Wire resolve_real_tool_binary into all four wrappers: bwopencode, bwclaude, bwcodex, bwcopilot. Assisted-by: opencode:claude-opus-4-8 Assisted-by: opencode:claude-sonnet-4-6 Assisted-by: opencode:gpt-5.6-sol --- bin/bwclaude | 2 +- bin/bwcodex | 2 +- bin/bwcopilot | 2 +- bin/bwopencode | 2 +- lib/bwrap_sandbox_lib.sh | 56 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/bin/bwclaude b/bin/bwclaude index a22a085..2656efe 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -149,7 +149,7 @@ parse_wrapper_args() { # not depend on PATH lookup inside the sandbox. # Sets globals: _TOOL_BIN, _TOOL_CMD resolve_tool_binary() { - _TOOL_BIN="$(command -v claude 2> /dev/null || true)" + _TOOL_BIN="$(resolve_real_tool_binary claude)" if [[ -z "${_TOOL_BIN}" ]]; then echo "Error: 'claude' not found in PATH." >&2 exit 1 diff --git a/bin/bwcodex b/bin/bwcodex index 7f3b346..8695d9b 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -177,7 +177,7 @@ parse_wrapper_args() { # not depend on PATH lookup inside the sandbox. # Sets globals: _TOOL_BIN, _TOOL_CMD resolve_tool_binary() { - _TOOL_BIN="$(command -v codex 2> /dev/null || true)" + _TOOL_BIN="$(resolve_real_tool_binary codex)" if [[ -z "${_TOOL_BIN}" ]]; then echo "Error: 'codex' not found in PATH." >&2 exit 1 diff --git a/bin/bwcopilot b/bin/bwcopilot index 437b65d..4d2cb8e 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -141,7 +141,7 @@ parse_wrapper_args() { # archived/deprecated as of Oct 2025. # Sets globals: _TOOL_BIN, _TOOL_CMD resolve_tool_binary() { - _TOOL_BIN="$(command -v copilot 2> /dev/null || true)" + _TOOL_BIN="$(resolve_real_tool_binary copilot)" if [[ -z "${_TOOL_BIN}" ]]; then echo "Error: 'copilot' not found in PATH." >&2 echo "Install the standalone GitHub Copilot CLI from https://github.com/github/copilot-cli" >&2 diff --git a/bin/bwopencode b/bin/bwopencode index fbd76f4..1d6321d 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -135,7 +135,7 @@ parse_wrapper_args() { # not depend on PATH lookup inside the sandbox. # Sets globals: _TOOL_BIN, _TOOL_CMD resolve_tool_binary() { - _TOOL_BIN="$(command -v opencode 2> /dev/null || true)" + _TOOL_BIN="$(resolve_real_tool_binary opencode)" if [[ -z "${_TOOL_BIN}" ]]; then echo "Error: 'opencode' not found in PATH." >&2 exit 1 diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 024c510..842d172 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -180,6 +180,62 @@ _get_npm_prefix() { printf '%s' "${_NPM_PREFIX}" } +# resolve_real_tool_binary TOOL_NAME +# Return the path to the real (non-wrapper) binary for TOOL_NAME. +# +# Problem: on managed hosts the dotfiles wrapper (e.g. ~/.local/bin/opencode) +# precedes /usr/local/bin on PATH. `command -v opencode` therefore resolves +# to the wrapper, not the npm-installed binary. resolve_and_mount_tool then +# adds the wrapper's directory to SANDBOX_PATH and bind-mounts it into the +# --clearenv sandbox. Inside the sandbox the wrapper is visible on PATH, but +# the env vars it needs (N2SNSCRIPTS_LIB, AZURE_*) are stripped, so it fails +# with "cannot locate gpg-passwd.sh" — the double-invocation bug. +# +# Fix: if `command -v TOOL_NAME` resolves to a dotfiles wrapper symlink +# (detected by checking whether `readlink -f candidate` contains +# "/config/scripts/"), skip it and return the npm-installed binary from +# /bin/TOOL_NAME instead. If the npm binary is also absent, +# that means the real tool is not installed — exit with an error rather +# than silently returning the wrapper (which would recreate the bug). +# +# If the candidate is NOT a dotfiles wrapper (system package, already the +# correct npm binary, etc.) it is returned as-is so non-npm tools are +# unaffected. +# +# Outputs the resolved path on stdout. Callers assign with $(...). +resolve_real_tool_binary() { + local tool_name="$1" + local candidate real_candidate npm_prefix npm_bin + + candidate="$(command -v "${tool_name}" 2> /dev/null || true)" + if [[ -z "${candidate}" ]]; then + # Tool not on PATH at all — callers handle the empty-string case. + printf '%s' "${candidate}" + return 0 + fi + + # Detect dotfiles wrapper: the symlink target contains /config/scripts/ + # (the dotfiles convention for per-tool wrapper scripts). + real_candidate="$(readlink -f "${candidate}" 2> /dev/null || printf '%s' "${candidate}")" + if [[ "${real_candidate}" == */config/scripts/* ]]; then + # candidate is a dotfiles wrapper — resolve to the real npm binary. + npm_prefix="$(_get_npm_prefix)" + npm_bin="${npm_prefix:+${npm_prefix}/bin/${tool_name}}" + if [[ -n "${npm_bin}" && -x "${npm_bin}" ]]; then + printf '%s' "${npm_bin}" + return 0 + fi + # The real binary is not installed. Returning the wrapper would + # recreate the double-invocation bug; error out instead. + printf 'resolve_real_tool_binary: %s resolves to a dotfiles wrapper but %s is not installed or not executable.\n' \ + "${candidate}" "${tool_name}" >&2 + return 1 + fi + + # Not a dotfiles wrapper — return the candidate unchanged. + printf '%s' "${candidate}" +} + # _emit_home_intermediate_dirs DIR [MODE] # Emit `--dir` BWRAP_ARGS for the ancestor components of DIR that live # under $HOME, so the eventual bind mount point exists inside the tmpfs From cb57193133068749349894679629024d4dc24148 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sat, 18 Jul 2026 08:16:31 -0400 Subject: [PATCH 2/4] ENH: opt-in GH_TOKEN_* passthrough for bwopencode Add --github-tokens to forward GH_TOKEN_* env vars into the sandbox so the agent can authenticate gh(1) via per-command token selection. Off by default; values are printed as REDACTED under --dry-run so tokens never reach the terminal. Assisted-by: opencode:claude-opus-4-8 Assisted-by: opencode:claude-sonnet-4-6 --- bin/bwopencode | 30 ++++++++++++++++++++++++++++++ docs/bw-wrappers.md | 4 +++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/bin/bwopencode b/bin/bwopencode index 1d6321d..7d45093 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -72,6 +72,7 @@ source "${SCRIPT_DIR}/../lib/bwrap_sandbox_lib.sh" # ═══════════════════════════════════════════════════════════════════ INIT_AUTH=0 +FORWARD_GH_TOKENS=0 OC_CONFIG_BINDS=() # ═══════════════════════════════════════════════════════════════════ @@ -101,6 +102,10 @@ parse_wrapper_args() { INIT_AUTH=1 shift ;; + --github-tokens) + FORWARD_GH_TOKENS=1 + shift + ;; --new-session) # shellcheck disable=SC2034 # consumed by sourced bwrap_sandbox_lib.sh (sandboxed_exec) FORCE_NEW_SESSION=1 @@ -179,6 +184,11 @@ Wrapper options (consumed before opencode sees the command line): Symlinks are resolved before the safety check is applied. --rw-path PATH Mount PATH (file or directory) read-write into the sandbox. Same blocked-path rules as --ro-path. May be repeated. + --github-tokens Forward any GH_TOKEN_* environment variables into the + sandbox so the agent can authenticate the gh(1) CLI. + Off by default. When set, use per-command token selection: + GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo + GH_TOKEN="$GH_TOKEN_PERSONAL" gh issue list -R user/repo All other arguments are passed through to opencode unchanged. @@ -366,6 +376,9 @@ build_opencode_cache_mount() { # ── OpenCode env vars ──────────────────────────────────────────── # Pass through any OPENCODE_* env vars (config overrides, etc.) # Also pass through AIFAPIM_* env vars for NSLS-II Hermes gateway. +# With --github-tokens, also forward GH_TOKEN_* vars so the agent +# can authenticate gh(1) via per-command: +# GH_TOKEN="$GH_TOKEN_NSLS2" gh build_opencode_env() { # AIFAPIM gateway config pass_through_if_set AIFAPIM_HOST @@ -377,6 +390,23 @@ build_opencode_env() { BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") fi done < <(compgen -e) + + # GitHub CLI token passthrough (opt-in via --github-tokens) + # In --dry-run mode the value is replaced with REDACTED so that tokens + # are not printed to the terminal in the bwrap command output. + if [[ "${FORWARD_GH_TOKENS}" -eq 1 ]]; then + local _gh_val + while IFS= read -r _key; do + if [[ "${_key}" == GH_TOKEN_* ]]; then + if [[ "${DRY_RUN}" -eq 1 ]]; then + _gh_val="REDACTED" + else + _gh_val="${!_key}" + fi + BWRAP_ARGS+=(--setenv "${_key}" "${_gh_val}") + fi + done < <(compgen -e) + fi } # ═══════════════════════════════════════════════════════════════════ diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 82b4ec1..79d0551 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -46,7 +46,9 @@ project-dotfiles to the host data dir; `auth.json` is persisted only if it already exists on the host (use `--init-auth` once to create it for personal accounts). -No options beyond the common set. +| Option | Description | +| --- | --- | +| `--github-tokens` | Forward every `GH_TOKEN_*` environment variable into the sandbox so the agent can authenticate `gh`. Off by default. In `--dry-run` mode, token values are printed as `REDACTED`. Use per-command token selection inside the sandbox: `GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo` | ## `bwclaude` From cd2a1f838ce5b7239b64e08c297ff1dca5230812 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sat, 18 Jul 2026 08:30:49 -0400 Subject: [PATCH 3/4] ENH: add branch-creation restriction to gh-protect-branch Add --restrict-branch-creation and --only-restrict-creation to apply an additive nsls2-restrict-branch-creation ruleset targeting ~ALL branches, blocking creation of names outside --exclude-branches (default main,preview; fnmatch, auto-prefixed refs/heads/). Validate --approvers team via API before applying (GitHub silently ignores unknown dismissal teams; requires read:org scope). Guard against empty --exclude-branches patterns. Document exit codes accurately. Rebased onto current main; conflict resolution preserved bash>=4 guard from ad7eca9 and pemdecompose row from upstream. Assisted-by: opencode:claude-opus-4-8 Assisted-by: opencode:claude-sonnet-4-6 Assisted-by: opencode:gpt-5.6-sol --- README.md | 38 ++++- bin/gh-protect-branch | 317 +++++++++++++++++++++++++++++--------- docs/gh-protect-branch.md | 105 ++++++++++--- 3 files changed, 366 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 5a4474e..7c68fb2 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ docs/ Per-script and per-library documentation | `bwcodex` | OpenAI Codex CLI in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) | | `bwcopilot` | GitHub Copilot CLI in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) | | `bwopencode` | OpenCode in a bubblewrap sandbox | [docs/bw-wrappers.md](docs/bw-wrappers.md) | -| `gh-protect-branch` | Apply NSLS-II standard branch protection to a GitHub repo (all branches); enables secret scanning and push protection | [docs/gh-protect-branch.md](docs/gh-protect-branch.md) | +| `gh-protect-branch` | Apply NSLS-II standard branch protection to a GitHub repo (all branches); enables secret scanning and push protection; optionally restricts branch creation to named patterns | [docs/gh-protect-branch.md](docs/gh-protect-branch.md) | | `pemdecompose` | List and verify certificates in a PEM file | [docs/pemdecompose.md](docs/pemdecompose.md) | All `bw*` wrappers share `lib/bwrap_sandbox_lib.sh` for sandbox construction. @@ -99,6 +99,42 @@ Common wrapper options: `bwclaude` also supports `--debug`. See each script's `--help` for the authoritative option list. +### `gh-protect-branch` + +Apply NSLS-II standard branch protection to a GitHub repository. Enforces +PR reviews, signed commits, deletion and force-push blocking, and secret +scanning via a repository ruleset and a named-branch classic protection +rule. Optionally restricts who can create new branches. + +```text +gh-protect-branch [--approvers ] [--no-sign-commits] + [--restrict-branch-creation [--exclude-branches ]] + [branch] +gh-protect-branch --only-restrict-creation [--exclude-branches ] + +``` + +| Option | Effect | +| --- | --- | +| `--approvers ` | Restrict stale-review dismissal to this team (team must exist in the org) | +| `--no-sign-commits` | Do not require signed commits | +| `--restrict-branch-creation` | Also apply a creation-restriction ruleset (additive) | +| `--only-restrict-creation` | Apply only the creation-restriction ruleset; skip full protection | +| `--exclude-branches ` | Comma-separated fnmatch patterns allowed to be created (default: `main,preview`); requires `--restrict-branch-creation` or `--only-restrict-creation` | +| `` | Repository in `owner/repo` format (required) | +| `[branch]` | Named branch for classic protection (default: `main`) | +| `-h`, `--help` | Show help and exit | + +```bash +gh-protect-branch NSLS2/n2sndocs +gh-protect-branch --approvers n2sn-admins NSLS2/n2sndocs main +gh-protect-branch --only-restrict-creation \ + --exclude-branches main,preview NSLS2/n2sndocs +``` + +Exit codes: `0` success, `1` usage error or missing dependency, +`2` `gh` not authenticated, non-zero `gh` exit code on API failure. + ### `gpg-passwd.sh` Source the library and call `decrypt_env_file`: diff --git a/bin/gh-protect-branch b/bin/gh-protect-branch index 579019c..a3b4488 100755 --- a/bin/gh-protect-branch +++ b/bin/gh-protect-branch @@ -23,25 +23,58 @@ # Repository settings: # - Secret scanning and push protection enabled # +# Repository ruleset "nsls2-restrict-branch-creation" (optional): +# - Blocks creation of branches whose names do not match the excluded patterns +# - Excluded (allowed) patterns are supplied via --exclude-branches (CSV) +# - Applied additively alongside full protection (--restrict-branch-creation) +# or as the only change to the repo (--only-restrict-creation) +# # Requires: # - gh(1) authenticated with admin:repo scope on the target repository +# - read:org scope when using --approvers (GitHub team API requirement) # # Usage: -# gh-protect-branch [--approvers ] [--no-sign-commits] [branch] +# gh-protect-branch [--approvers ] [--no-sign-commits] +# [--restrict-branch-creation [--exclude-branches ]] +# [branch] +# gh-protect-branch --only-restrict-creation [--exclude-branches ] +# # gh-protect-branch --help # +# Options: +# --approvers Restrict stale-review dismissal to this team. +# The team must exist in the repo's org; the script +# validates this before applying any protection. +# --no-sign-commits Do not require signed commits. +# --restrict-branch-creation Apply the creation-restriction ruleset in +# addition to full branch protection. +# --only-restrict-creation Apply only the creation-restriction ruleset; +# skip full branch protection, classic rules, +# and secret-scanning changes. +# Incompatible with --approvers and --no-sign-commits. +# --exclude-branches Comma-separated list of fnmatch branch-name +# patterns to allow creation of. Each entry is +# matched against refs/heads/. +# Default: main,preview +# Requires --restrict-branch-creation or +# --only-restrict-creation. +# # Examples: # gh-protect-branch NSLS2/n2snscripts # gh-protect-branch NSLS2/n2sndocs main # gh-protect-branch --approvers n2sn-admins NSLS2/n2snscripts -# gh-protect-branch --approvers n2sn-admins NSLS2/n2sndocs main # gh-protect-branch --no-sign-commits NSLS2/n2snscripts +# gh-protect-branch --restrict-branch-creation NSLS2/n2sndocs +# gh-protect-branch --restrict-branch-creation \ +# --exclude-branches main,preview,release/** NSLS2/n2sndocs +# gh-protect-branch --only-restrict-creation \ +# --exclude-branches main,preview NSLS2/n2sndocs # # Exit codes: # 0 success -# 1 usage error or missing dependency +# 1 usage error or missing dependency (including team not found / read:org) # 2 gh not authenticated -# 3 API call failed +# non-zero gh API call failed (exit code from gh; typically 1) set -euo pipefail @@ -50,7 +83,8 @@ if ((BASH_VERSINFO[0] < 4)); then exit 1 fi -RULESET_NAME="nsls2-branch-protection" +FULL_RULESET_NAME="nsls2-branch-protection" +CREATION_RULESET_NAME="nsls2-restrict-branch-creation" show_help() { sed -n '2,/^$/{ s/^# \{0,1\}//; p; }' "$0" @@ -58,6 +92,10 @@ show_help() { APPROVERS_TEAM="" SIGN_COMMITS="true" +RESTRICT_CREATION="false" +ONLY_RESTRICT_CREATION="false" +EXCLUDE_CSV="main,preview" +EXCLUDE_CSV_SET="false" while [[ "${1:-}" == -* ]]; do case "$1" in @@ -77,6 +115,23 @@ while [[ "${1:-}" == -* ]]; do SIGN_COMMITS="false" shift ;; + --restrict-branch-creation) + RESTRICT_CREATION="true" + shift + ;; + --only-restrict-creation) + ONLY_RESTRICT_CREATION="true" + shift + ;; + --exclude-branches) + if [[ -z "${2:-}" ]]; then + echo "Error: --exclude-branches requires a comma-separated list of patterns." >&2 + exit 1 + fi + EXCLUDE_CSV="$2" + EXCLUDE_CSV_SET="true" + shift 2 + ;; *) echo "Error: unknown option: $1" >&2 show_help @@ -85,6 +140,32 @@ while [[ "${1:-}" == -* ]]; do esac done +# ── Validate flag combinations ───────────────────────────────────────────────── + +if [[ "${RESTRICT_CREATION}" == "true" && "${ONLY_RESTRICT_CREATION}" == "true" ]]; then + echo "Error: --restrict-branch-creation and --only-restrict-creation are mutually exclusive." >&2 + exit 1 +fi + +if [[ "${ONLY_RESTRICT_CREATION}" == "true" && -n "${APPROVERS_TEAM}" ]]; then + echo "Error: --only-restrict-creation is incompatible with --approvers." >&2 + exit 1 +fi + +if [[ "${ONLY_RESTRICT_CREATION}" == "true" && "${SIGN_COMMITS}" == "false" ]]; then + echo "Error: --only-restrict-creation is incompatible with --no-sign-commits." >&2 + exit 1 +fi + +if [[ "${EXCLUDE_CSV_SET}" == "true" && + "${RESTRICT_CREATION}" == "false" && + "${ONLY_RESTRICT_CREATION}" == "false" ]]; then + echo "Error: --exclude-branches requires --restrict-branch-creation or --only-restrict-creation." >&2 + exit 1 +fi + +# ── Positional arguments ─────────────────────────────────────────────────────── + if [[ -z "${1:-}" ]]; then show_help exit 1 @@ -99,6 +180,8 @@ if [[ ! "${REPO}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then exit 1 fi +# ── Dependency + auth checks ─────────────────────────────────────────────────── + command -v gh > /dev/null || { echo "Error: gh CLI not found in PATH." >&2 exit 1 @@ -108,18 +191,60 @@ gh auth status > /dev/null 2>&1 || { exit 2 } -echo "Applying branch protection to ${REPO}@${BRANCH}..." +# ── Validate --approvers team exists in the org ──────────────────────────────── +# Note: the GitHub team API (orgs/{org}/teams/{slug}) requires read:org scope +# in addition to admin:repo. A 404 may mean the team does not exist OR that +# the token lacks read:org; the error message covers both cases. -# Build the rules array for the ruleset. Signed commits are conditional. -if [[ "${SIGN_COMMITS}" == "true" ]]; then - SIGN_RULE=', - { "type": "required_signatures" }' -else - SIGN_RULE='' +if [[ -n "${APPROVERS_TEAM}" ]]; then + ORG="${REPO%%/*}" + if ! gh api "orgs/${ORG}/teams/${APPROVERS_TEAM}" > /dev/null 2>&1; then + echo "Error: team '${APPROVERS_TEAM}' not found in org '${ORG}'," >&2 + echo " or gh token lacks read:org scope required by the team API." >&2 + exit 1 + fi fi -# Compose the ruleset payload (used for both create and update). -RULESET_PAYLOAD=$(printf '{ +# ── Build the creation-restriction ruleset exclude JSON ─────────────────────── +# Each CSV entry is trimmed and prefixed with refs/heads/ so bare names like +# "main" match correctly. fnmatch patterns (e.g. release/**) are preserved. + +_build_exclude_json() { + local csv="$1" + local result="" + local p + IFS=',' read -ra _pats <<< "${csv}" + for p in "${_pats[@]}"; do + # trim leading/trailing whitespace + p="${p#"${p%%[![:space:]]*}"}" + p="${p%"${p##*[![:space:]]}"}" + [[ -z "$p" ]] && continue + result+="${result:+, }\"refs/heads/${p}\"" + done + if [[ -z "${result}" ]]; then + echo "Error: --exclude-branches produced no usable patterns after trimming." >&2 + echo " At least one non-empty branch name or pattern is required." >&2 + exit 1 + fi + printf '%s' "${result}" +} + +# ── Full branch protection (steps 1–4) ──────────────────────────────────────── +# Skipped when --only-restrict-creation is set. + +if [[ "${ONLY_RESTRICT_CREATION}" == "false" ]]; then + echo "Applying branch protection to ${REPO}@${BRANCH}..." + + # Build the rules array. Signed commits are conditional. + if [[ "${SIGN_COMMITS}" == "true" ]]; then + SIGN_RULE=', + { "type": "required_signatures" }' + else + SIGN_RULE='' + fi + + # Compose the ruleset payload (used for both create and update). + RULESET_PAYLOAD=$(printf '{ "name": "%s", "target": "branch", "enforcement": "active", @@ -143,38 +268,38 @@ RULESET_PAYLOAD=$(printf '{ } }%s ] -}' "${RULESET_NAME}" "${SIGN_RULE}") +}' "${FULL_RULESET_NAME}" "${SIGN_RULE}") -# 1) Repository ruleset (all branches via ~ALL) — idempotent. -# Check for an existing ruleset with our name; PUT if found, POST if not. -RULESET_ID=$(gh api "repos/${REPO}/rulesets" \ - --jq ".[] | select(.name==\"${RULESET_NAME}\") | .id" 2> /dev/null || true) + # 1) Repository ruleset (all branches via ~ALL) — idempotent. + # Check for an existing ruleset with our name; PUT if found, POST if not. + RULESET_ID=$(gh api "repos/${REPO}/rulesets" \ + --jq ".[] | select(.name==\"${FULL_RULESET_NAME}\") | .id" 2> /dev/null || true) -if [[ -n "${RULESET_ID}" ]]; then - printf '%s' "${RULESET_PAYLOAD}" | - gh api -X PUT "repos/${REPO}/rulesets/${RULESET_ID}" \ - -H "Accept: application/vnd.github+json" \ - --input - > /dev/null - RULESET_ACTION="updated" -else - printf '%s' "${RULESET_PAYLOAD}" | - gh api -X POST "repos/${REPO}/rulesets" \ - -H "Accept: application/vnd.github+json" \ - --input - > /dev/null - RULESET_ACTION="created" -fi + if [[ -n "${RULESET_ID}" ]]; then + printf '%s' "${RULESET_PAYLOAD}" | + gh api -X PUT "repos/${REPO}/rulesets/${RULESET_ID}" \ + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null + RULESET_ACTION="updated" + else + printf '%s' "${RULESET_PAYLOAD}" | + gh api -X POST "repos/${REPO}/rulesets" \ + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null + RULESET_ACTION="created" + fi -# 2) Classic named-branch protection — enforce_admins + optional dismissal restrictions. -# The Ruleset covers the substantive rules for all branches; the classic rule -# adds admin enforcement on the named branch and (when --approvers is set) -# restricts who can dismiss stale reviews to the named team. -if [[ -n "${APPROVERS_TEAM}" ]]; then - DISMISSAL_JSON='"dismissal_restrictions": { "teams": ["'"${APPROVERS_TEAM}"'"] },' -else - DISMISSAL_JSON='' -fi + # 2) Classic named-branch protection — enforce_admins + optional dismissal + # restrictions. The Ruleset covers the substantive rules for all + # branches; the classic rule adds admin enforcement on the named branch + # and (when --approvers is set) restricts who can dismiss stale reviews. + if [[ -n "${APPROVERS_TEAM}" ]]; then + DISMISSAL_JSON='"dismissal_restrictions": { "teams": ["'"${APPROVERS_TEAM}"'"] },' + else + DISMISSAL_JSON='' + fi -printf '{ + printf '{ "required_status_checks": null, "enforce_admins": true, "required_pull_request_reviews": { @@ -187,41 +312,89 @@ printf '{ "restrictions": null, "required_conversation_resolution": true }' "${DISMISSAL_JSON}" | - gh api -X PUT "repos/${REPO}/branches/${BRANCH}/protection" \ - -H "Accept: application/vnd.github+json" \ - --input - > /dev/null - -# 3) Belt-and-suspenders signed commits on the named branch via classic API. -# The Ruleset already requires signed commits on ~ALL branches, but the classic -# required_signatures endpoint provides an additional enforcement layer on the -# named branch. -if [[ "${SIGN_COMMITS}" == "true" ]]; then - gh api -X POST "repos/${REPO}/branches/${BRANCH}/protection/required_signatures" \ - -H "Accept: application/vnd.github+json" > /dev/null -fi + gh api -X PUT "repos/${REPO}/branches/${BRANCH}/protection" \ + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null -# 4) Secret scanning + push protection (repository-level) -printf '{ + # 3) Belt-and-suspenders signed commits on the named branch via classic API. + # The Ruleset already requires signed commits on ~ALL branches, but the + # classic required_signatures endpoint adds an enforcement layer on the + # named branch. + if [[ "${SIGN_COMMITS}" == "true" ]]; then + gh api -X POST \ + "repos/${REPO}/branches/${BRANCH}/protection/required_signatures" \ + -H "Accept: application/vnd.github+json" > /dev/null + fi + + # 4) Secret scanning + push protection (repository-level). + printf '{ "security_and_analysis": { "secret_scanning": { "status": "enabled" }, "secret_scanning_push_protection": { "status": "enabled" } } }' | gh api -X PATCH "repos/${REPO}" \ - -H "Accept: application/vnd.github+json" \ - --input - > /dev/null - -echo "Done. ${REPO}@${BRANCH} protected:" -echo " - Ruleset '${RULESET_NAME}' ${RULESET_ACTION} (all branches via ~ALL)" -echo " PR + 1 approval + last-push + CODEOWNERS + conversation resolution" -echo " + deletion blocked + force-push blocked" -if [[ "${SIGN_COMMITS}" == "true" ]]; then - echo " + signed commits required" -fi -echo " - Classic protection on ${BRANCH}: admins enforced" -if [[ -n "${APPROVERS_TEAM}" ]]; then - echo " + stale-review dismissal restricted to team '${APPROVERS_TEAM}'" + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null + + echo "Done. ${REPO}@${BRANCH} protected:" + echo " - Ruleset '${FULL_RULESET_NAME}' ${RULESET_ACTION} (all branches via ~ALL)" + echo " PR + 1 approval + last-push + CODEOWNERS + conversation resolution" + echo " + deletion blocked + force-push blocked" + if [[ "${SIGN_COMMITS}" == "true" ]]; then + echo " + signed commits required" + fi + echo " - Classic protection on ${BRANCH}: admins enforced" + if [[ -n "${APPROVERS_TEAM}" ]]; then + echo " + stale-review dismissal restricted to team '${APPROVERS_TEAM}'" + fi + if [[ "${SIGN_COMMITS}" == "true" ]]; then + echo " + signed commits required on ${BRANCH}" + fi + echo " - Secret scanning and push protection enabled" fi -if [[ "${SIGN_COMMITS}" == "true" ]]; then - echo " + signed commits required on ${BRANCH}" + +# ── Creation-restriction ruleset (step 5) ───────────────────────────────────── +# Applied when --restrict-branch-creation or --only-restrict-creation is set. + +if [[ "${RESTRICT_CREATION}" == "true" || "${ONLY_RESTRICT_CREATION}" == "true" ]]; then + EXCLUDE_JSON=$(_build_exclude_json "${EXCLUDE_CSV}") + + CREATION_PAYLOAD=$(printf '{ + "name": "%s", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~ALL"], + "exclude": [%s] + } + }, + "rules": [ + { "type": "creation" } + ] +}' "${CREATION_RULESET_NAME}" "${EXCLUDE_JSON}") + + CREATION_RULESET_ID=$(gh api "repos/${REPO}/rulesets" \ + --jq ".[] | select(.name==\"${CREATION_RULESET_NAME}\") | .id" \ + 2> /dev/null || true) + + if [[ -n "${CREATION_RULESET_ID}" ]]; then + printf '%s' "${CREATION_PAYLOAD}" | + gh api -X PUT "repos/${REPO}/rulesets/${CREATION_RULESET_ID}" \ + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null + CREATION_ACTION="updated" + else + printf '%s' "${CREATION_PAYLOAD}" | + gh api -X POST "repos/${REPO}/rulesets" \ + -H "Accept: application/vnd.github+json" \ + --input - > /dev/null + CREATION_ACTION="created" + fi + + if [[ "${ONLY_RESTRICT_CREATION}" == "true" ]]; then + echo "Done. ${REPO} creation restricted:" + fi + echo " - Ruleset '${CREATION_RULESET_NAME}' ${CREATION_ACTION}" + echo " Branch creation blocked except: ${EXCLUDE_CSV}" fi -echo " - Secret scanning and push protection enabled" diff --git a/docs/gh-protect-branch.md b/docs/gh-protect-branch.md index 6c8366a..9b52041 100644 --- a/docs/gh-protect-branch.md +++ b/docs/gh-protect-branch.md @@ -6,8 +6,7 @@ through `gh(1)`. Enforces the full NSLS-II branch protection policy across two layers. Because the script is named `gh-*`, it is also auto-discovered by `gh` -as an extension, so -`gh protect-branch [--approvers ] [--no-sign-commits] [branch]` +as an extension, so `gh protect-branch [branch]` works equivalently. ## Protections applied @@ -35,8 +34,8 @@ rather than creating a duplicate. - Optionally restricts stale-review dismissal to a named GitHub team (`--approvers `) — only members of that team can dismiss a stale review, so a team member must re-approve after new commits are - pushed; the team slug must belong to the same GitHub organization as - the repo + pushed; the team slug is validated against the GitHub API before + protection is applied (a nonexistent team is a hard error) - Belt-and-suspenders signed commits on the named branch (in addition to the ruleset enforcement above) @@ -44,41 +43,105 @@ rather than creating a duplicate. - Secret scanning and push protection enabled +### Branch creation restriction (optional) + +A second ruleset named `nsls2-restrict-branch-creation` targeting +`~ALL` branches can be applied in addition to, or instead of, full +branch protection. It blocks creation of any branch whose name does not +match one of the excluded (allowed) patterns. + +Allowed patterns are supplied via `--exclude-branches` as a +comma-separated list of fnmatch branch-name strings. Each entry is +automatically prefixed with `refs/heads/` so a bare name such as `main` +matches correctly. fnmatch wildcards (`*`, `**`, `?`) are supported, so +`release/**` allows nested names like `release/2025/q1`. + +The default excluded patterns (when `--exclude-branches` is omitted) +are `main,preview`. + ## Usage ```text -gh-protect-branch [--approvers ] [--no-sign-commits] [branch] +gh-protect-branch [--approvers ] [--no-sign-commits] + [--restrict-branch-creation [--exclude-branches ]] + [branch] +gh-protect-branch --only-restrict-creation [--exclude-branches ] + +gh-protect-branch --help ``` | Option / Argument | Description | | --- | --- | -| `--approvers ` | GitHub team slug whose members are the only ones allowed to dismiss stale reviews (optional; org-owned repos only) | -| `--no-sign-commits` | Skip enforcing signed commits on the named branch (signing is required by default) | -| `` | Repository in `owner/repo` format (required) | -| `[branch]` | Primary branch to protect (default: `main`); ruleset protection is always applied to all branches regardless | +| `--approvers ` | GitHub team slug whose members are the only ones allowed to dismiss stale reviews. The team is validated via the GitHub API before protection is applied (requires `read:org` scope in addition to `admin:repo`). | +| `--no-sign-commits` | Skip enforcing signed commits. Incompatible with `--only-restrict-creation`. | +| `--restrict-branch-creation` | Apply the creation-restriction ruleset in addition to full branch protection. Mutually exclusive with `--only-restrict-creation`. | +| `--only-restrict-creation` | Apply only the creation-restriction ruleset; skip full branch protection, classic rules, and secret-scanning changes. Mutually exclusive with `--restrict-branch-creation`, `--approvers`, and `--no-sign-commits`. | +| `--exclude-branches ` | Comma-separated fnmatch patterns for branches whose creation is allowed. Default: `main,preview`. At least one non-empty pattern is required. Requires `--restrict-branch-creation` or `--only-restrict-creation`. | +| `` | Repository in `owner/repo` format (required). | +| `[branch]` | Primary branch to protect via the classic API (default: `main`). The ruleset always targets all branches regardless of this argument. Not used with `--only-restrict-creation`. | ## Examples ```bash -# Standard protection (no team restriction) +# Standard full protection gh-protect-branch NSLS2/n2snscripts -# With approver team restriction +# Standard protection on a non-default branch +gh-protect-branch NSLS2/n2sndocs main + +# With approver team restriction (team validated before applying) gh-protect-branch --approvers n2sn-admins NSLS2/n2snscripts gh-protect-branch --approvers n2sn-admins NSLS2/n2sndocs main -# Skip the signed-commit requirement (e.g. for repos where signing is not yet rolled out) +# Skip the signed-commit requirement gh-protect-branch --no-sign-commits NSLS2/n2snscripts + +# Full protection plus branch creation restriction (default: allow main,preview) +gh-protect-branch --restrict-branch-creation NSLS2/n2sndocs + +# Full protection plus creation restriction with custom allowed patterns +gh-protect-branch --restrict-branch-creation \ + --exclude-branches main,preview,release/** NSLS2/n2sndocs + +# Creation restriction only (no PR/signing changes) +gh-protect-branch --only-restrict-creation NSLS2/n2sndocs + +# Creation restriction only with custom allowed patterns +gh-protect-branch --only-restrict-creation \ + --exclude-branches main,preview NSLS2/n2sndocs ``` +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Success | +| `1` | Usage error or missing dependency (includes team-not-found / `read:org` missing) | +| `2` | `gh` not authenticated | +| non-zero | `gh` API call failed; exit code is from `gh` (typically `1`) | + ## Caveats -Requires `gh(1)` authenticated with `admin:repo` scope on the target -repository. Secret scanning requires the repository to belong to an -organization on GitHub Team or Enterprise Cloud (or to be a public -repository). The `require_code_owner_review` rule is a no-op if no -`CODEOWNERS` file exists or no entry matches the changed files. Re-runs -are idempotent: the `nsls2-branch-protection` ruleset is updated in place -rather than duplicated. Signed-commit enforcement is applied to all -branches via the ruleset and additionally as a belt-and-suspenders rule -on the named branch via the classic `required_signatures` endpoint. +- Requires `gh(1)` authenticated with `admin:repo` scope on the target + repository. The `--approvers` preflight check also requires `read:org` + scope (GitHub team API requirement); a 404 may mean the team does not + exist **or** that the token lacks `read:org`. +- Secret scanning requires the repository to belong to an organization on + GitHub Team or Enterprise Cloud, or to be a public repository. +- Repository rulesets (both `nsls2-branch-protection` and + `nsls2-restrict-branch-creation`) require GitHub Team or Enterprise on + private repositories. +- The `--approvers` team slug is validated via the GitHub API before any + protection is applied. GitHub's branch-protection API silently ignores + unknown teams in `dismissal_restrictions`; the pre-flight check catches + this and exits with an error rather than applying a misconfigured rule. +- The `require_code_owner_review` rule is a no-op if no `CODEOWNERS` file + exists or no entry matches the changed files. +- Re-runs are idempotent: both rulesets are updated in place rather than + duplicated. +- Signed-commit enforcement is applied to all branches via the ruleset + and additionally as a belt-and-suspenders rule on the named branch via + the classic `required_signatures` endpoint. +- fnmatch patterns in `--exclude-branches` are automatically prefixed + with `refs/heads/`. Do not include `refs/heads/` yourself; it would + produce a doubled prefix. From affb87e0ce0af47e8050f417adc40a116f5a322b Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sat, 18 Jul 2026 17:34:46 -0400 Subject: [PATCH 4/4] docs: add AI_DISCLOSURE.md and link from README Assisted-by: opencode:claude-sonnet-5 --- AI_DISCLOSURE.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 5 +++ 2 files changed, 108 insertions(+) create mode 100644 AI_DISCLOSURE.md diff --git a/AI_DISCLOSURE.md b/AI_DISCLOSURE.md new file mode 100644 index 0000000..1e01efd --- /dev/null +++ b/AI_DISCLOSURE.md @@ -0,0 +1,103 @@ +--- +tools-used: + - OpenCode +models-used: + - claude-opus-4-8 + - claude-sonnet-4-6 + - claude-sonnet-5 + - gpt-5.6-sol +providers: + - Anthropic + - OpenAI +scope: + human-authored: > + Repository policy and governance text, and final human review of + all merged changes. + ai-generated: > + Shell wrapper scripts (bin/, lib/), the `azoidcapp` Python helper, + documentation (docs/), and CI/lint configuration — all with human + review and validation. +last-updated: 2026-07-18 +--- + +# AI Disclosure + +This file describes how AI tools are used in this repository. + +## Disclosure levels + +This repository uses the vocabulary defined by the +[W3C AI Content Disclosure Community Group](https://www.w3.org/community/ai-content-disclosure/) +(a community-group effort, not a ratified W3C standard). +Four levels are defined: + +| Level | Meaning | +| --- | --- | +| `none` | No AI tools were used. | +| `ai-assisted` | AI contributed, but a human authored and reviewed. | +| `ai-generated` | AI generated the content; a human reviewed it. | +| `autonomous` | AI produced the content without human review. | + +Per the community group's "absence = unknown" principle, files +without an explicit disclosure tag do not imply a specific level. + +## Scope of AI use in this repository + +**Repository policy and governance text** — including this disclosure +file — is human-authored. The final text is authored and validated by +human contributors. + +**Source code in this repository is largely AI-generated:** AI tools +produce the initial implementation of the sandboxed AI CLI wrapper +scripts (`bin/bwopencode`, `bin/bwcodex`, `bin/bwclaude`, +`bin/bwcopilot`) and the shared `lib/bwrap_sandbox_lib.sh` sandbox +library, as well as `bin/gh-protect-branch`, `bin/azoidcapp`, +`bin/pemdecompose`, `lib/gpg-passwd.sh`, associated documentation, and +CI/lint configuration. A human contributor reviews, tests, and +validates all content before merging; the human remains accountable +for every merged change. + +## Tools and models + +AI assistance in this repository is provided via +**OpenCode** using Anthropic models for implementation and OpenAI GPT-5.6-sol +for critical code review. See the metadata block at the top of this file for +the current list of models. + +Per-commit attribution uses the `Assisted-by: AGENT:MODEL` trailer +format. Those per-commit trailers are the authoritative record of +which model contributed to a specific change. + +## Purpose of use + +AI tools are used to: + +- Draft and refactor the bubblewrap-sandboxed AI CLI wrapper scripts + (`bwopencode`, `bwcodex`, `bwclaude`, `bwcopilot`) and the shared + `bwrap_sandbox_lib.sh` library. +- Draft and refactor `gh-protect-branch`, `azoidcapp`, `pemdecompose`, + and `gpg-passwd.sh`. +- Perform critical code review (GPT-5.6-sol). +- Generate commit messages, code review responses, and documentation. + +## Input data / datasets + +Only repository source files, public documentation, and +non-sensitive prompts are provided as input to AI models. + +## Limitations and known biases + +- LLM output may contain errors, hallucinations, or bias + inherited from training data. +- All AI-generated content is independently reviewed and validated + by a human contributor before it is merged. +- The models listed above do not have access to internal BNL + systems, live data, or non-public information unless explicitly + provided in a prompt. + +## Reviewer disclaimer + +AI-generated content in this repository has been reviewed. +Inclusion of AI-generated material does not substitute for human +accountability: the contributing staff member is responsible for the +correctness and appropriateness of every merged change. diff --git a/README.md b/README.md index 7c68fb2..aed71d3 100644 --- a/README.md +++ b/README.md @@ -230,3 +230,8 @@ Requires only `openssl`. See the per-script doc pages under [`docs/`](docs/) for full usage, options, and caveats. + +## AI disclosure + +This repository's contents are largely AI-generated with human +review. See [`AI_DISCLOSURE.md`](AI_DISCLOSURE.md) for details.