diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index cd8a7dd..4f1f3b5 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -92,7 +92,9 @@ jobs: && bash -n governance/deadman-trip.sh && bash -n governance/butler-audit.sh \ && bash -n governance/tests/test-drill.sh \ && bash -n governance/drill/tests/test-samples.sh && bash -n governance/drill/tests/test-select.sh \ - && bash -n governance/drill/tests/test-verify.sh + && bash -n governance/drill/tests/test-verify.sh \ + && bash -n governance/drill/tests/test-history.sh && bash -n governance/drill/tests/test-failclose.sh \ + && bash -n governance/drill/failclose-test.sh echo "OK scripts" - name: 治理脚本自测(governance/tests) run: | diff --git a/governance/drill/failclose-test.sh b/governance/drill/failclose-test.sh new file mode 100644 index 0000000..3ffa261 --- /dev/null +++ b/governance/drill/failclose-test.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# failclose-test.sh —— 缺席 fail-closed 演练(宪法 §6 / ADR-0069 决策 6 / .github#223 AC-3) +# +# 回归 #180 先例(管家缺席 → dead-man trip → 自动合并停):模拟"审计包/心跳 +# 缺席超时",断言 AUTO_MERGE_DISABLED 置位路径真的会触发。三层: +# 1) 谓词单测(无网络):心跳新鲜→不 trip / 缺席超时→trip——注入时间戳断言, +# 与 butler-heartbeat-watch 同一判据(age > deadman_stale_hours); +# 2) 真源检查(只读):当前真实心跳新鲜度 + 阈值(butler.yaml),只报告不动状态; +# 3) 置位路径实演(FAILCLOSE_DRY_RUN=0 时): +# PATCH org 变量 AUTO_MERGE_DISABLED=true → GET 读回断言 → **立即复位** → +# GET 读回断言 false,输出置位/复位时戳与窗口时长(记录进演练台账)。 +# 复位用 trap 保证异常退出也尝试——真置位不复位=打断线上自动合并(不可留)。 +# 端点勘误与 deadman-trip.sh 一致:POST 须打集合端点 /actions/variables。 +# 凭据不足时默认 FAILCLOSE_DRY_RUN=1:只输出"将要置位"的判定与精确 API 调用 +# 作为证据,不动真变量(不打断线上自动合并)。 +# 用法: [FAILCLOSE_DRY_RUN=1|0] [FAILCLOSE_STALE_HOURS=N] bash failclose-test.sh +# 退出码: 0=演练通过(含 dry-run 断言通过)| 1=断言失败 | 2=infra(API 不可达等) +set -uo pipefail + +ORG="${DRILL_ORG:-Cloudbird-Software}" +CB=AUTO_MERGE_DISABLED +DRY_RUN="${FAILCLOSE_DRY_RUN:-1}" +THRESH_H="${FAILCLOSE_STALE_HOURS:-}" +# python3 解析(CI 恒有 python3;本地 python3 可能是商店 stub——实测可用性而非 command -v) +pick_py() { + local c + for c in "${PYTHON:-}" python3 python py -3; do + [[ -n "$c" ]] || continue + "$c" -c 'import sys, yaml; print("ok")' >/dev/null 2>&1 || continue + echo "$c"; return 0 + done + return 1 +} +PY3="$(pick_py)" || { echo "::error::无可用 python(含 pyyaml)——阈值真源不可读" >&2; exit 2; } +DIR="$(cd "$(dirname "$0")/.." && pwd)" +NOW() { date -u +%FT%TZ; } +INFRA=0 +SET_AT=""; RESET_AT="" + +ok() { echo "OK $1"; } +act() { echo "ACT $1"; } +infra(){ echo "INFRA $1" >&2; INFRA=$((INFRA+1)); } +audit(){ echo "AUDIT | butler=failclose-drill | run_id=${GITHUB_RUN_ID:-local} | ts=$(NOW) | outcome=$1 | actions=$2"; } + +# ---------- 1) 谓词单测(无网络;注入时间戳——BUTLER_DRY_RUN 类 env 注入模式) ---------- +# 谓词与 butler-heartbeat-watch 完全同式: AGE_S > THRESH_H*3600 → trip +trip_if_stale() { # $1=last_success_epoch $2=threshold_hours → echo trip|no-trip + local age=$(( $(date -u +%s) - $1 )) + if (( age > $2 * 3600 )); then echo "trip"; else echo "no-trip"; fi +} +predicate_selftest() { + local pass=0 fail=0 + # 新鲜心跳(30 分钟前)→ 不 trip + r=$(trip_if_stale "$(( $(date -u +%s) - 1800 ))" "${THRESH_H:-3}") + [[ "$r" == "no-trip" ]] && { ok "谓词: 心跳 30min 前且阈值 ${THRESH_H:-3}h → no-trip"; pass=$((pass+1)); } \ + || { echo "::error::谓词失败: 新鲜心跳被判 $r"; fail=$((fail+1)); } + # 缺席超时(365 天前)→ trip(模拟审计包/心跳缺席超时) + r=$(trip_if_stale "$(( $(date -u +%s) - 31536000 ))" "${THRESH_H:-3}") + [[ "$r" == "trip" ]] && { ok "谓词: 心跳缺席 365d > 阈值 ${THRESH_H:-3}h → trip(缺席即停判定在)"; pass=$((pass+1)); } \ + || { echo "::error::谓词失败: 缺席超时被判 $r——fail-closed 判定缺失"; fail=$((fail+1)); } + # 边界: 恰好等于阈值 → 不 trip(严格大于才停,与 heartbeat-watch 一致) + r=$(trip_if_stale "$(( $(date -u +%s) - $(( ${THRESH_H:-3} * 3600 )) ))" "${THRESH_H:-3}") + [[ "$r" == "no-trip" ]] && { ok "谓词: 恰在阈值=边界(严格>才 trip)"; pass=$((pass+1)); } \ + || { echo "::error::谓词失败: 边界语义漂移"; fail=$((fail+1)); } + echo "PREDICATE pass=$pass fail=$fail" + [[ $fail -eq 0 ]] +} + +# ---------- 2) 真源检查(只读) ---------- +threshold_from_butler() { + if [[ -z "$THRESH_H" ]]; then + # 经 stdin 喂文件(本地 Windows python 解析不了 MSYS 路径;CI Linux 同样适用) + THRESH_H=$("$PY3" -c 'import sys, yaml; print(yaml.safe_load(sys.stdin)["thresholds"]["deadman_stale_hours"])' \ + < "$DIR/policy/butler.yaml" | tr -d '\r') || { + echo "::error::butler.yaml thresholds.deadman_stale_hours 读取失败" >&2; return 2; } + fi + [[ "$THRESH_H" =~ ^[0-9]+([.][0-9]+)?$ ]] || { echo "::error::阈值非数值: $THRESH_H" >&2; return 2; } +} +probe_live_heartbeat() { # 只读:当前真实心跳新鲜度(不动任何状态) + if [[ -z "${GH_TOKEN:-}" ]]; then + ok "真实心跳: 无 GH_TOKEN,跳过只读探针(谓词单测已覆盖判定;真置位模式另行强制凭据)" + return 0 + fi + local last age + last=$(gh api "repos/$ORG/.github/actions/workflows/butler-heartbeat.yml/runs?status=success&per_page=1" \ + --jq '.workflow_runs[0].updated_at // empty' 2>/dev/null) || { infra "heartbeat runs 查询失败"; return 0; } + if [[ -z "$last" ]]; then + ok "真实心跳: 无成功 run(若长期如此,watch 层会 trip——本演练不动状态)" + else + age=$(( ( $(date -u +%s) - $(date -u -d "$last" +%s) ) / 60 )) + ok "真实心跳: 最近成功 ${age}min 前(阈值 ${THRESH_H}h)——只读探针,无动作" + fi +} + +# ---------- 3) 置位路径实演 ---------- +var_get() { gh api "orgs/$ORG/actions/variables/$CB" --jq .value 2>/dev/null || echo "ABSENT"; } +var_set() { # $1=true|false —— 与 deadman-trip.sh 同端点(POST 打集合端点) + if ! gh api -X PATCH "orgs/$ORG/actions/variables/$CB" -f name="$CB" -f value="$1" >/dev/null 2>&1; then + gh api -X POST "orgs/$ORG/actions/variables" -f name="$CB" -f value="$1" -f visibility=all >/dev/null 2>&1 + fi +} +reset_trap() { # 异常退出也复位(真置位不可留——宪法 §6 停机须人工确认后人工复位, + # 但**演练**置位必须在演练内复位并留时戳;此 trap 只兜异常,正常路径下方显式复位) + if [[ -n "$SET_AT" && -z "$RESET_AT" ]]; then + var_set false && infra "异常退出兜底复位已执行(详见台账)" + fi +} +drill_set_reset() { + if [[ "$DRY_RUN" != "0" ]]; then + ok "DRY-RUN 演练: 缺席超时谓词已判 trip,**将要执行** 置位: gh api -X PATCH orgs/$ORG/actions/variables/$CB -f name=$CB -f value=true" + ok "DRY-RUN 证据: 判定输出(上方谓词行)即 fail-closed 触发链的置位前一刻证据;未动真实变量" + audit dry-run-pass '{"breaker":"would-set","variable":"'"$CB"'"}' + return 0 + fi + [[ -n "${GH_TOKEN:-}" ]] || { echo "::error::真置位需 GH_TOKEN(org admin 写变量)" >&2; return 2; } + trap reset_trap EXIT + act "实演置位: $CB=true($(NOW))" + SET_AT=$(NOW) + var_set true + local v; v=$(var_get) + [[ "$v" == "true" ]] || { echo "::error::置位后读回=$v(期望 true)——置位路径失效"; audit real-fail '{"breaker":"set-readback-mismatch"}'; return 1; } + ok "置位路径验证: 读回=true(缺席即停路径触发,#180 先例回归通过)" + act "立即复位: $CB=false($(NOW))" + var_set false + RESET_AT=$(NOW) + v=$(var_get) + [[ "$v" == "false" ]] || { echo "::error::复位后读回=$v(期望 false)——必须人工立即复位: gh api -X PATCH orgs/$ORG/actions/variables/$CB -f name=$CB -f value=false" >&2; audit real-fail '{"breaker":"reset-readback-mismatch"}'; return 1; } + ok "复位验证: 读回=false(复位时戳 $RESET_AT,置位窗口已闭合,线上自动合并不受影响)" + audit real-pass '{"breaker":"set-and-reset","set_at":"'"$SET_AT"'","reset_at":"'"$RESET_AT"'"}' + trap - EXIT +} + +main() { + audit running '{"phase":"start","dry_run":"'"$DRY_RUN"'"}' + threshold_from_butler || exit 2 # 阈值先验证后使用(注入值/真源值同一条路径) + predicate_selftest || { audit predicate-fail '{"phase":"predicate"}'; exit 1; } + probe_live_heartbeat + drill_set_reset; rc=$? + if [[ $INFRA -gt 0 ]]; then audit infra-fail "{\"infra_failures\":$INFRA}"; exit 2; fi + exit $rc +} +main diff --git a/governance/drill/tests/test-failclose.sh b/governance/drill/tests/test-failclose.sh new file mode 100644 index 0000000..fdd983e --- /dev/null +++ b/governance/drill/tests/test-failclose.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# test-failclose.sh —— 缺席 fail-closed 演练 dry-run 断言自测(W4-C4 AC-3) +# 只跑 dry-run 路径(不动 org 真变量 AUTO_MERGE_DISABLED——真置位路径由季度 +# 演练/显式 FAILCLOSE_DRY_RUN=0 承担,本测试永不触发)。 +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +PASS=0; FAIL=0 +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT + +run_fc() { env -u GH_TOKEN FAILCLOSE_STALE_HOURS=3 FAILCLOSE_DRY_RUN=1 bash "$ROOT/failclose-test.sh"; } + +echo "== 1) dry-run 全链通过(谓词表 + 将要置位证据,不动真变量)" +OUT=$(run_fc | tee "$TMP/fc.log"); RC=${PIPESTATUS[0]} +[[ $RC -eq 0 ]] && grep -q "缺席超时.*trip\|→ trip" "$TMP/fc.log" \ + && { PASS=$((PASS+1)); echo "ok 谓词: 缺席超时→trip 判定在"; } \ + || { FAIL=$((FAIL+1)); echo "FAIL 谓词/退出码 rc=$RC"; } +grep -q "心跳 30min 前.*no-trip\|新鲜.*no-trip" "$TMP/fc.log" \ + && { PASS=$((PASS+1)); echo "ok 谓词: 新鲜心跳不误杀"; } \ + || { FAIL=$((FAIL+1)); echo "FAIL 新鲜心跳谓词"; } +grep -q "将要执行.*AUTO_MERGE_DISABLED\|将要执行" "$TMP/fc.log" \ + && { PASS=$((PASS+1)); echo "ok dry-run 输出'将要置位'判定证据(凭据不足时的替代证据路径)"; } \ + || { FAIL=$((FAIL+1)); echo "FAIL dry-run 证据行缺失"; } +grep -q "dry-run-pass" "$TMP/fc.log" \ + && { PASS=$((PASS+1)); echo "ok AUDIT dry-run-pass 留痕"; } \ + || { FAIL=$((FAIL+1)); echo "FAIL dry-run 审计行缺失"; } + +echo "== 2) fail-closed 语义(真置位拒绝无凭据执行,不半演)" +OUT=$(env -u GH_TOKEN FAILCLOSE_DRY_RUN=0 bash "$ROOT/failclose-test.sh" 2>&1); RC=$? +if [[ $RC -eq 2 ]] && grep -q "真置位需 GH_TOKEN" <<<"$OUT"; then + PASS=$((PASS+1)); echo "ok 真置位模式缺凭据→infra 拒绝(exit2)" +else FAIL=$((FAIL+1)); echo "FAIL 真置位无凭据 rc=$RC"; fi + +echo "== 3) 阈值非法值被拒(真源防护)" +OUT=$(env -u GH_TOKEN FAILCLOSE_STALE_HOURS=abc FAILCLOSE_DRY_RUN=1 bash "$ROOT/failclose-test.sh" 2>&1); RC=$? +[[ $RC -eq 2 ]] && { PASS=$((PASS+1)); echo "ok 非数值阈值→exit2"; } \ + || { FAIL=$((FAIL+1)); echo "FAIL 非法阈值 rc=$RC"; } + +echo "failclose 自测: pass=$PASS fail=$FAIL" +[[ $FAIL -eq 0 ]] diff --git a/governance/drill/tests/test-history.sh b/governance/drill/tests/test-history.sh new file mode 100644 index 0000000..4befa3e --- /dev/null +++ b/governance/drill/tests/test-history.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# test-history.sh —— 台账 append-only + 红率/难度趋势聚合自测(W4-C4 AC-4) +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +source "$(cd "$(dirname "$0")" && pwd)/lib.sh" +PYTHON="$(pick_py)" || { echo "::error::无可用 python(含 pyyaml)"; exit 2; } +PASS=0; FAIL=0 +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +H="$TMP/history.jsonl" +D="$ROOT/drill.py" + +rec() { printf '%s' "$1"; } + +echo "== 1) record 追加 + append-only 破坏被拒" +"$PYTHON" "$D" record --history "$H" --json '{"ts":"2026-08-22T04:23:11Z","kind":"seed-drill","run_id":"r1","sample_id":"hygiene-gitleaks-aws-key","difficulty":"easy","gate":"org-hygiene","target_repo":".github","branch":"drill/seed-20260822","head_sha":"aaa","surface":"draft-pr","verdict":"red"}' >/dev/null +[[ $(wc -l < "$H") -eq 1 ]] && { PASS=$((PASS+1)); echo "ok 首条追加"; } || { FAIL=$((FAIL+1)); echo "FAIL 首条追加"; } +"$PYTHON" "$D" record --history "$H" --json '{"ts":"2026-08-22T05:00:00Z","kind":"seed-drill","run_id":"r2","sample_id":"gate-yaml-parse-corrupt","difficulty":"medium","gate":"gate","target_repo":".github","verdict":"red"}' >/dev/null +[[ $(wc -l < "$H") -eq 2 ]] && { PASS=$((PASS+1)); echo "ok 顺序追加"; } || { FAIL=$((FAIL+1)); echo "FAIL 顺序追加"; } +"$PYTHON" "$D" record --history "$H" --json '{"ts":"2026-08-21T00:00:00Z","kind":"seed-drill","run_id":"r3"}' >/dev/null 2>&1 +[[ $? -ne 0 ]] && { PASS=$((PASS+1)); echo "ok 时间戳回拨被拒(append-only)"; } || { FAIL=$((FAIL+1)); echo "FAIL 回拨被放行"; } +"$PYTHON" "$D" record --history "$H" --json '{"ts":"2026-08-22T06:00:00Z","kind":"seed-drill","run_id":"r1"}' >/dev/null 2>&1 +[[ $? -ne 0 ]] && { PASS=$((PASS+1)); echo "ok 同 run 重复记录被拒"; } || { FAIL=$((FAIL+1)); echo "FAIL 重复 run 被放行"; } +"$PYTHON" "$D" record --history "$H" --json 'not-json' >/dev/null 2>&1 +[[ $? -ne 0 ]] && { PASS=$((PASS+1)); echo "ok 畸形 JSON 被拒"; } || { FAIL=$((FAIL+1)); echo "FAIL 畸形被放行"; } +PRE=$(head -1 "$H") +"$PYTHON" "$D" record --history "$H" --json '{"ts":"2026-08-22T07:00:00Z","kind":"seed-drill","run_id":"r4","difficulty":"hard","gate":"gate","verdict":"red"}' >/dev/null +[[ "$(head -1 "$H")" == "$PRE" ]] && { PASS=$((PASS+1)); echo "ok 追加不改写既有行"; } || { FAIL=$((FAIL+1)); echo "FAIL 既有行被改写"; } + +echo "== 2) redrate 聚合(红率 + 难度趋势 + 零分母诚实口径 + 告警出口)" +OUT=$("$PYTHON" "$D" redrate --history "$H") +echo "$OUT" | "$PYTHON" -c ' +import json, sys +d = json.load(sys.stdin) +assert d["seed_drills"] == 3, d +assert d["verdicts"] == {"red": 3}, d +assert d["red_rate"] == 1.0, d +day = d["difficulty_trend_by_day"]["2026-08-22"] +assert day.get("easy") == 1 and day.get("medium") == 1 and day.get("hard") == 1, day +' && { PASS=$((PASS+1)); echo "ok 红=3/3 rate=1.0 且难度分布可见"; } \ + || { FAIL=$((FAIL+1)); echo "FAIL 聚合口径: $OUT"; } +printf '%s\n' '{"ts":"2026-08-29T04:23:11Z","kind":"seed-drill","run_id":"r5","difficulty":"easy","gate":"org-hygiene","verdict":"green"}' >> "$H" +OUT=$("$PYTHON" "$D" redrate --history "$H") +echo "$OUT" | "$PYTHON" -c ' +import json, sys +d = json.load(sys.stdin) +assert d["red_rate"] == 0.75, d # 3红/4可判定(no-surface 不入分母) +' && { PASS=$((PASS+1)); echo "ok green 入账拉低红率(0.75)"; } \ + || { FAIL=$((FAIL+1)); echo "FAIL 红率计算: $OUT"; } +"$PYTHON" "$D" redrate --history "$H" --fail-unhealthy >/dev/null 2>&1 +[[ $? -ne 0 ]] && { PASS=$((PASS+1)); echo "ok 红率<100% 触发告警退出(AC-4 告警出口)"; } \ + || { FAIL=$((FAIL+1)); echo "FAIL 告警出口未触发"; } +H2="$TMP/empty.jsonl"; : > "$H2" +OUT=$("$PYTHON" "$D" redrate --history "$H2") +echo "$OUT" | grep -q '"red_rate": null' && { PASS=$((PASS+1)); echo "ok 零分母→null(不除零不出假 100%)"; } \ + || { FAIL=$((FAIL+1)); echo "FAIL 零分母口径: $OUT"; } + +echo "台账自测: pass=$PASS fail=$FAIL" +[[ $FAIL -eq 0 ]]