From afd83a2a549716fbc3a2d59ef1ba1ed13a354d6a Mon Sep 17 00:00:00 2001 From: selftest Date: Fri, 21 Aug 2026 19:50:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(governance):=20holdout=20=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E6=96=AD=E8=A8=80=20=C2=A718=20+=20=E6=B3=84=E6=BC=8F?= =?UTF-8?q?=E8=AF=B1=E9=A5=B5=E5=B7=A1=E6=A3=80=20sweep/drill=EF=BC=88W1-C?= =?UTF-8?q?4=20#167=EF=BC=8CADR-0056=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/holdout-canary-drill.yml | 72 ++++++ .github/workflows/holdout-canary-sweep.yml | 243 +++++++++++++++++++++ governance/REPOS.yaml | 8 + governance/drift-check.sh | 63 ++++++ governance/expected-state.json | 3 + 5 files changed, 389 insertions(+) create mode 100644 .github/workflows/holdout-canary-drill.yml create mode 100644 .github/workflows/holdout-canary-sweep.yml diff --git a/.github/workflows/holdout-canary-drill.yml b/.github/workflows/holdout-canary-drill.yml new file mode 100644 index 0000000..a2882e0 --- /dev/null +++ b/.github/workflows/holdout-canary-drill.yml @@ -0,0 +1,72 @@ +name: holdout-canary-drill +# W1-C4 演习(ADR-0056 决策 7;宪法 §4B"每周向系统注入已知缺陷、演习关卡与 +# holdout 是否真会变红"的 holdout 变体):把一条已注册 drill marker 写进本 run +# 日志,模拟"holdout 内容泄漏进 workflow 日志"。 +# 演习序列(owner 月度补给职责的一部分,手动触发留痕): +# 1. dispatch 本 workflow → 本 run 日志含 drill marker +# 2. dispatch holdout-canary-sweep(treat_drill_as_leak=true,since_days 覆盖本 run) +# 3. 断言 sweep 开出 P0 holdout-leak issue(报警通道端到端真的会触发——AC-3) +# 4. 手动关闭该 issue 并留评论"演习" +# 仅 workflow_dispatch(无 cron):演习由 owner 手动执行,留审计痕迹。 +on: + workflow_dispatch: + inputs: + marker_entry: + description: "演习用条目 id(缺省=registry 第一条 drill: true;只允许 drill 条目——真饵进日志会触发真 P0)" + default: "" + required: false + +permissions: {} + +concurrency: + group: holdout-canary-drill + cancel-in-progress: false + +jobs: + drill: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - run: pip install pyyaml + - name: 选定 drill marker(公开仓 raw 读取,钉 commit 留审计) + env: + MARKER_ENTRY: ${{ inputs.marker_entry }} + run: | + set -euo pipefail + SHA=$(curl -sSf "https://api.github.com/repos/Cloudbird-Software/holdout/commits?path=canary/registry.yaml&per_page=1" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["sha"])') + curl -sSfL "https://raw.githubusercontent.com/Cloudbird-Software/holdout/$SHA/canary/registry.yaml" -o registry.snapshot.yaml + MARKER_ENTRY="$MARKER_ENTRY" SHA="$SHA" python3 - <<'EOF' >> drill.env + import os, sys, yaml + want = os.environ.get("MARKER_ENTRY", "").strip() + reg = yaml.safe_load(open("registry.snapshot.yaml", encoding="utf-8")) + rows = [m for m in reg["markers"] if m.get("drill") is True] + if not rows: + print("::error::registry 无 drill:true marker——正控缺失,先补 registry(ADR-0056 决策 5)", file=sys.stderr) + sys.exit(2) + if want: + row = next((m for m in reg["markers"] if m.get("id") == want), None) + if row is None: + print(f"::error::registry 无条目 {want}", file=sys.stderr); sys.exit(2) + if row.get("drill") is not True: + # 铁闸:真饵 marker 进日志 = 真实 P0 泄漏报警——绝不注入 + print(f"::error::{want} 不是 drill:true 条目,拒绝注入(真饵进日志会触发真 P0)", file=sys.stderr) + sys.exit(2) + else: + row = rows[0] + print(f"DRILL_MARKER={row['marker']}") + print(f"DRILL_ENTRY={row['id']}") + print(f"REG_SHA8={os.environ['SHA'][:8]}") + EOF + cat drill.env >> "$GITHUB_ENV" + - name: 泄漏注入(演习——drill marker 写入本 run 日志) + run: | + echo "=== holdout 泄漏演习(ADR-0056 决策 7)===" + echo "(演习)模拟 holdout 内容泄漏进 workflow 日志:entry=$DRILL_ENTRY registry@$REG_SHA8" + echo "leaked-canary-marker=$DRILL_MARKER" + echo "(演习)后续:dispatch holdout-canary-sweep(treat_drill_as_leak=true, since_days 覆盖本 run) → 应开出 P0 holdout-leak issue → 关闭留评论'演习'" diff --git a/.github/workflows/holdout-canary-sweep.yml b/.github/workflows/holdout-canary-sweep.yml new file mode 100644 index 0000000..f12c3bb --- /dev/null +++ b/.github/workflows/holdout-canary-sweep.yml @@ -0,0 +1,243 @@ +name: holdout-canary-sweep +# W1-C4(ADR-0056,宪法 §4B/§6 + §11 cron 每周行):holdout 泄漏诱饵巡检。 +# holdout 内容(canary marker)出现在任何受管仓 workflow 日志 = 违规读取,报警: +# - 命中非 drill marker → P0:开 issue(label holdout-leak,幂等)+ run 变红 +# - 命中 drill marker → 演习正控在场:treat_drill_as_leak=false 报"检测通道健康" +# (绿);=true(演习模式)同样开 P0——证明报警通道真的会触发(AC-3 演习路径) +# - 全部未命中 → "正控缺失" P1 变红(fail-closed:正控必须在场——检测器失明 +# 不得伪装成无泄漏,宪法 §6 缺席触发思想) +# 扫描面:REPOS.yaml 全部 active 仓(含 holdout 自身——无豁免,隔离不变量对全仓 +# 一致执法);每仓窗口内上限 50 个 run,超限 ::warning 注记(P1 覆盖注记)。 +# 注意:本 workflow 自身日志在扫描范围内——一切输出必须掩码 marker(只允许 +# entry id + 末 4 位),否则检测器自己就是泄漏源。 +on: + schedule: + - cron: "31 3 * * 1" # 每周一 03:31 UTC(错峰:避开整点 drift 洪峰与 flaky-sweep) + workflow_dispatch: + inputs: + treat_drill_as_leak: + description: "演习模式:drill marker 命中按真泄漏开 P0(AC-3 演习用)" + type: boolean + default: false + since_days: + description: "扫描窗口(天;演习时须覆盖 drill run 的产生时间)" + type: number + default: 7 + +permissions: {} + +concurrency: + group: holdout-canary-sweep + cancel-in-progress: false + +jobs: + sweep: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read # checkout 读 REPOS.yaml + issues: write # P0 泄漏 issue(GITHUB_TOKEN;API 读走 GOVERNANCE_TOKEN env) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - run: pip install pyyaml + - name: 拉取 holdout canary registry(钉 commit,审计记录 sha) + env: + GH_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} + run: | + set -euo pipefail + SHA=$(gh api "repos/Cloudbird-Software/holdout/commits?path=canary/registry.yaml&per_page=1" --jq '.[0].sha') + if [[ -z "$SHA" || "$SHA" == "null" ]]; then + echo "::error::holdout canary/registry.yaml commit sha 拉取失败——扫描失去判据(fail-closed)"; exit 2 + fi + curl -sSfL "https://raw.githubusercontent.com/Cloudbird-Software/holdout/$SHA/canary/registry.yaml" -o registry.snapshot.yaml + python3 -c 'import yaml; d=yaml.safe_load(open("registry.snapshot.yaml",encoding="utf-8")); assert d.get("markers"), "registry 无 markers(fail-closed)"; print("registry markers:", len(d["markers"]))' + echo "REG_SHA8=${SHA:0:8}" >> "$GITHUB_ENV" + echo "registry pinned @ ${SHA:0:8}" + - name: 扫描全部 active 仓 workflow 日志 + env: + GOVERNANCE_TOKEN: ${{ secrets.GOVERNANCE_TOKEN }} + ORG: ${{ github.repository_owner }} + RUN_ID: ${{ github.run_id }} + SWEEP_TRIGGER: ${{ github.event_name }} + SINCE_DAYS: ${{ inputs.since_days || 7 }} + TREAT_DRILL_AS_LEAK: ${{ inputs.treat_drill_as_leak == true }} + run: | + python3 - <<'EOF' + import io, json, os, sys, time, urllib.error, urllib.parse, urllib.request, zipfile + from datetime import datetime, timedelta, timezone + import yaml + + TOKEN = os.environ["GOVERNANCE_TOKEN"] + ORG = os.environ["ORG"] + SELF_RUN_ID = int(os.environ["RUN_ID"]) + SINCE_DAYS = int(os.environ["SINCE_DAYS"]) + TREAT_DRILL = os.environ["TREAT_DRILL_AS_LEAK"] == "true" + MAX_RUNS_PER_REPO = 50 + + def fail_closed(msg): + print(f"::error::{msg}(fail-closed——检测器失明不得伪装通过)") + sys.exit(2) + + repos = [r["name"] for r in yaml.safe_load(open("governance/REPOS.yaml", encoding="utf-8"))["repos"] + if r.get("status") == "active"] + if not repos: + fail_closed("REPOS.yaml 无 active 仓") + reg = yaml.safe_load(open("registry.snapshot.yaml", encoding="utf-8")) + markers = {m["marker"]: {"id": m["id"], "drill": m.get("drill") is True} for m in reg["markers"]} + if not markers: + fail_closed("registry 无 markers") + + def api(path): + req = urllib.request.Request(f"https://api.github.com{path}", + headers={"Authorization": f"Bearer {TOKEN}", "Accept": "application/vnd.github+json", + "User-Agent": "holdout-canary-sweep"}) + with urllib.request.urlopen(req) as r: + return json.loads(r.read() or b"{}") + + class NoRedirect(urllib.request.HTTPRedirectHandler): + # 日志下载端点 302 → 签名 URL;手动跟随,避免把 GOVERNANCE_TOKEN 发给重定向目标 + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + _noredir = urllib.request.build_opener(NoRedirect) + + def fetch_logs_blob(repo, run_id): + req = urllib.request.Request( + f"https://api.github.com/repos/{ORG}/{repo}/actions/runs/{run_id}/logs", + headers={"Authorization": f"Bearer {TOKEN}", "User-Agent": "holdout-canary-sweep"}) + try: + try: + resp = _noredir.open(req) + loc, body = resp.headers.get("Location"), resp.read() + except urllib.error.HTTPError as e: + if e.code != 302: + return None + loc, body = e.headers.get("Location"), None + if body is None: + if not loc: + return None + with urllib.request.urlopen(loc) as r: # 签名 URL,无需凭据 + body = r.read() + z = zipfile.ZipFile(io.BytesIO(body)) + return b"".join(z.read(n) for n in z.namelist()) + except Exception: + return None # 单 run 日志缺失不废整轮——正控缺失判据兜底(见下) + + since = (datetime.now(timezone.utc) - timedelta(days=SINCE_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ") + hits, runs_scanned, capped = [], 0, [] + for repo in repos: + page, runs = 1, [] + while True: + q = urllib.parse.urlencode({"per_page": 100, "page": page, "created": ">" + since}) + try: + data = api(f"/repos/{ORG}/{repo}/actions/runs?{q}") + except Exception as e: + fail_closed(f"{repo} runs 清单拉取失败: {e}") + batch = data.get("workflow_runs") if isinstance(data, dict) else None + if not isinstance(batch, list): + fail_closed(f"{repo} runs 清单响应异常: {type(batch).__name__}") + runs.extend(batch) + if len(batch) < 100: + break + page += 1 + # 只扫已完结 run;跳过本轮 sweep 自身(日志未落全) + runs = [r for r in runs if r.get("status") == "completed" and r.get("id") != SELF_RUN_ID] + if len(runs) > MAX_RUNS_PER_REPO: + print(f"::warning::{repo} 窗口内 {len(runs)} 个 run 超上限 {MAX_RUNS_PER_REPO},只扫最近 {MAX_RUNS_PER_REPO}——P1 覆盖注记(人工核查或调窗口)") + capped.append(f"{repo}:{len(runs)}") + runs = runs[:MAX_RUNS_PER_REPO] + for r in runs: + blob = fetch_logs_blob(repo, r["id"]) + time.sleep(0.15) # 二级限流礼貌间隔 + if blob is None: + continue + runs_scanned += 1 + for marker, meta in markers.items(): + if marker.encode() in blob: + hits.append({"repo": repo, "run_id": r["id"], "url": r["html_url"], + "entry": meta["id"], "drill": meta["drill"]}) + + real_leaks = [h for h in hits if not h["drill"]] + drill_hits = [h for h in hits if h["drill"]] + result = {"real_leaks": real_leaks, "drill_hits": drill_hits, + "treat_drill_as_leak": TREAT_DRILL, "repos_scanned": len(repos), + "runs_scanned": runs_scanned, "capped": capped} + json.dump(result, open("sweep-result.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2) + print(f"AUDIT trigger={os.environ['SWEEP_TRIGGER']} registry={os.environ.get('REG_SHA8', 'unset')} " + f"repos={len(repos)} runs={runs_scanned} capped={capped or '无'} " + f"hits_real={len(real_leaks)} hits_drill={len(drill_hits)} since={since}") + # 输出一律掩码(entry id + drill 与否;完整 marker 绝不进日志) + for h in hits: + print(f"HIT {'drill' if h['drill'] else 'REAL-BAIT'} {h['repo']} run#{h['run_id']} ← {h['entry']}(marker 全文不进日志,见 registry 对应条目)") + EOF + - name: 结论与报警(P0 开 issue 幂等 / 正控缺失 P1 变红 / 正控在场绿) + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + REG_SHA8: ${{ env.REG_SHA8 }} + run: | + set -euo pipefail + VERDICT=$(python3 - <<'EOF' + import json + r = json.load(open("sweep-result.json", encoding="utf-8")) + if r["real_leaks"] or (r["drill_hits"] and r["treat_drill_as_leak"]): + print("LEAK") + elif r["drill_hits"]: + print("HEALTHY") + else: + print("NO-CONTROL") + EOF + ) + echo "verdict=$VERDICT" + if [[ "$VERDICT" == "HEALTHY" ]]; then + echo "演习正控命中 ✓ 检测通道健康(drill marker 在窗口内日志中被检出且无真饵命中——ADR-0056 决策 5/6)" + exit 0 + fi + if [[ "$VERDICT" == "NO-CONTROL" ]]; then + echo "::error::正控缺失 P1——窗口内未检出任何 drill marker,检测通道可能坏了(列表/下载/grep 任一环失效都会呈现为此形态)。处置:手动 dispatch holdout-canary-drill 注入正控 → dispatch 本 sweep(treat_drill_as_leak=false)复核;仍缺失则排查 Actions API 权限/限流(fail-closed,宪法 §6 缺席触发,ADR-0056)" + exit 1 + fi + # LEAK:P0 issue(幂等——已有 open 同 label 不重复开,追加评论) + python3 - <<'EOF' > issue-body.md + import json, os + r = json.load(open("sweep-result.json", encoding="utf-8")) + rows = r["real_leaks"] + ([h for h in r["drill_hits"]] if r["treat_drill_as_leak"] else []) + treat = r["treat_drill_as_leak"] + lines = [ + "## holdout 泄漏诱饵命中(P0" + ("——演习模式 treat_drill_as_leak=true" if treat else "") + ")", + "", + f"- 运行:{os.environ['REPO']}/actions/runs/{os.environ['RUN_ID']}", + f"- registry 钉点:{os.environ['REG_SHA8']}", + f"- 扫描面:{r['repos_scanned']} 仓 / {r['runs_scanned']} runs" + (f"(覆盖注记:{r['capped']})" if r['capped'] else ""), + "", + "命中明细(marker 已掩码,全文见 holdout canary/registry.yaml 对应 entry):", + ] + for h in rows: + kind = "drill(演习正控)" if h["drill"] else "真饵(违规读取)" + lines.append(f"- **{h['entry']}**({kind})→ {h['repo']} run #{h['run_id']}:{h['url']}") + lines += [ + "", + "处置(宪法 §6 / ADR-0056):定位该 run 的触发者与日志来源;确认真饵命中时审查对应 agent 的上下文来源,", + "确认隔离不变量是否被绕过(drift-check §18 的 App 安装面);处置完成后手动关闭本 issue。", + "", + "@randypanding", + ] + print("\n".join(lines)) + EOF + LABEL="holdout-leak" + gh label create "$LABEL" --repo "$REPO" --description "holdout 泄漏诱饵命中(canary sweep 自动报警,勿手工使用)" --color b60205 >/dev/null 2>&1 || true + EXISTING=$(gh issue list --repo "$REPO" --state open --label "$LABEL" --json number --jq 'length') + if [[ "$EXISTING" != "0" ]]; then + NUM=$(gh issue list --repo "$REPO" --state open --label "$LABEL" --json number --jq '.[0].number') + gh issue comment "$NUM" --repo "$REPO" --body-file issue-body.md + echo "已有 open $LABEL issue #$NUM,已追加本轮命中明细(幂等)" + else + gh issue create --repo "$REPO" --title "[P0] holdout 泄漏诱饵命中:canary marker 出现在 workflow 日志" --body-file issue-body.md --label "$LABEL" + fi + echo "::error::P0 泄漏命中——已开 issue(label holdout-leak)。明细见上方 HIT 行(marker 掩码)" + exit 1 diff --git a/governance/REPOS.yaml b/governance/REPOS.yaml index de47b5b..e261639 100644 --- a/governance/REPOS.yaml +++ b/governance/REPOS.yaml @@ -105,6 +105,14 @@ repos: 语言面收敛为 Go+BAML) key_paths: [src/mutual/, spec/, tests/golden/] + - name: holdout + layer: L1 + visibility: public + status: active + role: 试卷层——封存验收场景+golden+泄漏诱饵(宪法 §1/§4B,IR-0003 W1-C4 .github#167, + ADR-0056);owner 直管;App 不挂载(DECISION-02 隔离不变量,drift-check §18 断言) + key_paths: [entries/, schema/, canary/] + # 上游依赖(不属于本组织;不 fork、不 submodule) # 治理方式:声明于此 + 部署渲染时 clone 并 pin tag(ADR-0002 rev1) external_upstreams: diff --git a/governance/drift-check.sh b/governance/drift-check.sh index 484dbb7..00a69e2 100644 --- a/governance/drift-check.sh +++ b/governance/drift-check.sh @@ -665,6 +665,69 @@ else [[ $LBL_DRIFT -eq 0 ]] && ok "治理标签全集($LBL_N 项 × 受管仓)一致" fi +# ---------- 18. holdout 隔离断言(DECISION-02:App 安装差异隔离,W1-C4/ADR-0056)---------- +# 试卷层 holdout 的读隔离不靠保密(公开仓,ADR-0056/DECISION-02),靠两条: +# cloudbrid-agent App 不安装到该仓(agent 的组织级凭据通道物理不可达)+ 泄漏诱饵 +# 周检(.github 仓 holdout-canary-sweep,宪法 §6/§11)。本节断言第一条: +# App installation 的仓清单不得包含 holdout——在清单 = P0(隔离失效,agent 可持 +# 组织凭据读考卷)。正向对照防检测器失明假绿(§4 同思想):installation 清单必须 +# 非空且包含 .github——连 .github 都不在清单 = 端点读法错/权限变了 → 按漂移报错, +# 绝不把"看不见"当"没有"。API 任何失败 = fail-closed 报漂移。 +# §17 编号预留给并行卡 W1-C3(多代理并行修改本文件,编号互不占段)。 +# 端点注(2026-08-21 实测):installation id 走 /orgs//installations(admin +# 端点,new-repo-init.sh §3 同款)——/user/installations 要求 GitHub App 令牌, +# 经典 PAT 一律 403;仓清单走 /user/installations//repositories(经典 PAT 可读)。 +HOLDOUT_REPO="holdout" +HOLDOUT_INST=$(api "https://api.github.com/orgs/$ORG/installations?per_page=100") +if ! jq -e 'type == "object" and (.installations | type == "array")' <<<"$HOLDOUT_INST" >/dev/null 2>&1; then + drift "holdout 隔离断言失败:org installations 拉取失败(fail-closed——检测器失明不得伪装通过,ADR-0056 §18): $(jq -r '.message // "非 JSON 响应"' <<<"$HOLDOUT_INST" 2>/dev/null || echo 传输失败)" +else + # app 名取 expected-state 单一真源(与 §6 一致),不重复硬编码 + HOLDOUT_APP=$(jq -r .github_app.name "$EXPECTED") + INST_ID=$(jq -r --arg s "$HOLDOUT_APP" '.installations[]? | select(.app_slug == $s) | .id' <<<"$HOLDOUT_INST" | head -1) + if [[ -z "$INST_ID" || "$INST_ID" == "null" ]]; then + drift "app '$HOLDOUT_APP' 的 installation 未找到——holdout 隔离断言无法执行(fail-closed,ADR-0056 §18)" + else + # 全分页列出该 installation 下的仓(>100 仓单页漏检——§1/§4 同教训) + INST_TMP=$(mktemp) + INST_FAIL=0 + INST_PAGE=1 + while :; do + CHUNK=$(api "https://api.github.com/user/installations/$INST_ID/repositories?per_page=100&page=$INST_PAGE") + if ! jq -e 'type == "object" and (.repositories | type == "array")' <<<"$CHUNK" >/dev/null 2>&1; then + INST_FAIL=1; break + fi + INST_N=$(jq '.repositories | length' <<<"$CHUNK") + [[ "$INST_N" -eq 0 ]] && break + jq -r '.repositories[].name' <<<"$CHUNK" >>"$INST_TMP" + [[ "$INST_N" -lt 100 ]] && break + INST_PAGE=$((INST_PAGE+1)) + done + if [[ $INST_FAIL -ne 0 ]]; then + drift "installation#$INST_ID 仓清单拉取失败,holdout 隔离断言无法执行(fail-closed,ADR-0056 §18)" + rm -f "$INST_TMP" + else + INST_REPOS=$(sort -u "$INST_TMP") + rm -f "$INST_TMP" + HOLDOUT_HIT=0 + grep -qx "$HOLDOUT_REPO" <<<"$INST_REPOS" && HOLDOUT_HIT=1 + # 正向对照(防失明):清单非空且含 .github,否则断言结论不可信 + if [[ -z "$INST_REPOS" ]]; then + drift "installation#$INST_ID 仓清单为空——正向对照失败(端点读法或权限已变),不得把看不见当没有(fail-closed,ADR-0056 §18)" + HOLDOUT_HIT=-1 + elif ! grep -qx ".github" <<<"$INST_REPOS"; then + drift "installation#$INST_ID 仓清单不含 .github——正向对照失败(端点读法或权限已变),holdout 隔离结论不可信(fail-closed,ADR-0056 §18)" + HOLDOUT_HIT=-1 + fi + if [[ $HOLDOUT_HIT -eq 1 ]]; then + drift "holdout 出现在 app '$HOLDOUT_APP' installation#$INST_ID 仓清单——P0:App 挂上 holdout = 试卷层隔离失效(DECISION-02/ADR-0056 §18;立即在 App 设置页移除该仓访问并追查何时挂载)" + elif [[ $HOLDOUT_HIT -eq 0 ]]; then + ok "holdout 隔离成立(installation#$INST_ID 共 $(grep -c . <<<"$INST_REPOS") 仓不含 holdout;正向对照 .github 在清单)" + fi + fi + fi +fi + echo "----------------------------------------" if [[ $DRIFTS -gt 0 ]]; then echo "结果: $DRIFTS 项漂移。修复: bash governance/apply.sh 或手动改回" diff --git a/governance/expected-state.json b/governance/expected-state.json index 4fe6474..10200f8 100644 --- a/governance/expected-state.json +++ b/governance/expected-state.json @@ -144,6 +144,9 @@ "agent-registry": [ "60bd1557481fa89d0358302e150a8f7d9381309c", "e9424d220ded331c221b37135faa9d6e9cd1ecac" + ], + "holdout": [ + "63360c581524510ef37150dd7540cb333d283014" ] }, "merge_queue": {