diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index da9ffb7..777f61f 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -154,22 +154,73 @@ jobs: echo "::error::C1 路径变更(governance/standards/scripts/.github/CODEOWNERS)但 PR 未引用任何 ADR-NNNN(GOVERNANCE flows.governance_change C1:无 ADR 不合并)" exit 1 fi - # 存在性校验(ADR-0021):被引 ADR 须存在于公开仓 agent-registry/decisions/ - #(GITHUB_TOKEN 读公开仓;API 失败 fail-closed——检测器失明不得伪装通过) - ADR_LISTING=$(gh api "repos/Cloudbird-Software/agent-registry/contents/decisions?per_page=100" --paginate --jq '.[].name' 2>/dev/null) - if [[ -z "$ADR_LISTING" ]]; then - echo "::error::agent-registry/decisions 清单拉取失败——ADR 引用存在性无法校验(fail-closed)" + # 存在性校验(ADR-0021)+ 索引世界(ADR-0053/W1-C1 .github#164)双世界兼容: + # 先试拉 agent-registry decisions/INDEX.yaml(墓碑索引)—— + # 200 → 索引世界(迁移后):decisions/ 只留同名墓碑(文件名保留正是为了 + # 让本步骤的存在性清单与 org-gate v1.4.2 按文件名校验零改动兼容), + # 正本在 archive 仓 adr/。被引 ADR-NNNN 必须在 INDEX entries 且 + # archive_path 非空,并 HEAD 校验 archive 正本可达(raw 200)。 + # 404 → 旧世界(迁移未发生/已回滚):现有 decisions/ 清单逻辑原样。 + # 其他 → fail-closed(检测器失明不得伪装通过)。 + # 两世界共通不变量:引用不存在的编号一律 fail(防幽灵 ADR 语义不回归,#164 AC-2)。 + ADR_INDEX_JSON=$(gh api "repos/Cloudbird-Software/agent-registry/contents/decisions/INDEX.yaml" 2>&1 >/tmp/adr_index.json; echo "rc=$?") + if jq -e '.content' /tmp/adr_index.json >/dev/null 2>&1; then + ADR_WORLD="index" + base64 -d <<<"$(jq -r .content /tmp/adr_index.json)" >/tmp/adr_index.yaml 2>/dev/null + # entry → "NNNN archive_path" 行(pyyaml 已随本 job 前置步骤安装); + # archive_path 为空的 entry 不入 map(#96 扩展点:后续 substantive 字段 + # 不影响本映射——number/archive_path 是本关卡唯一消费的键) + python3 -c ' + import yaml + d = yaml.safe_load(open("/tmp/adr_index.yaml", encoding="utf-8")) + for e in d.get("entries") or []: + p = e.get("archive_path") + if p: + print(f"{int(e[\"number\"]):04d} {p}")' >/tmp/adr_map.txt + if [[ ! -s /tmp/adr_map.txt ]]; then + echo "::error::INDEX.yaml 存在但 entries 为空/解析失败——索引世界不可判定,fail-closed" + exit 1 + fi + elif grep -q "Not Found" <<<"$ADR_INDEX_JSON"; then + ADR_WORLD="legacy" + else + echo "::error::agent-registry decisions/INDEX.yaml 拉取失败(非 404)——索引世界判定失败,fail-closed" exit 1 fi + if [[ "$ADR_WORLD" == "legacy" ]]; then + ADR_LISTING=$(gh api "repos/Cloudbird-Software/agent-registry/contents/decisions?per_page=100" --paginate --jq '.[].name' 2>/dev/null) + if [[ -z "$ADR_LISTING" ]]; then + echo "::error::agent-registry/decisions 清单拉取失败——ADR 引用存在性无法校验(fail-closed)" + exit 1 + fi + fi MISSING=0 for ref in $( { echo "$PR_TITLE"; echo "$PR_BODY"; } | grep -oE "$ADR_RE" | sort -u ); do num="${ref#ADR-}" - if ! grep -q "^ADR-${num}-" <<<"$ADR_LISTING"; then + if [[ "$ADR_WORLD" == "index" ]]; then + # 索引世界:编号→archive_path→正本可达性(raw HEAD 200;网络/5xx 视同失败) + paths=$(awk -v n="$num" '$1 == n {print $2}' /tmp/adr_map.txt) + if [[ -z "$paths" ]]; then + echo "::error::引用的 ${ref} 不在 INDEX entries(幽灵 ADR——C1 决策背书不成立)" + MISSING=1 + continue + fi + reachable=0 + while IFS= read -r apath; do + code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 \ + "https://raw.githubusercontent.com/Cloudbird-Software/archive/main/${apath}" || echo 000) + [[ "$code" == "200" ]] && reachable=1 && break + done <<<"$paths" + if [[ $reachable -ne 1 ]]; then + echo "::error::${ref} 的 archive 正本不可达(${paths//$'\n'/,}——raw 非 200):INDEX 与 archive 仓漂移或归档未落位" + MISSING=1 + fi + elif ! grep -q "^ADR-${num}-" <<<"$ADR_LISTING"; then echo "::error::引用的 ${ref} 在 agent-registry/decisions/ 无对应文件(幽灵 ADR——C1 决策背书不成立)" MISSING=1 fi done if [[ $MISSING -eq 0 ]]; then - echo "OK adr-required: $( { echo "$PR_TITLE"; echo "$PR_BODY"; } | grep -oE "$ADR_RE" | sort -u | tr '\n' ' ')(存在性已验;实体性后验:drift-check §10)" + echo "OK adr-required[${ADR_WORLD}]: $( { echo "$PR_TITLE"; echo "$PR_BODY"; } | grep -oE "$ADR_RE" | sort -u | tr '\n' ' ')(存在性+正本可达性已验;实体性后验:drift-check §10)" fi exit $MISSING diff --git a/governance/REPOS.yaml b/governance/REPOS.yaml index e2262d8..084e359 100644 --- a/governance/REPOS.yaml +++ b/governance/REPOS.yaml @@ -42,6 +42,15 @@ repos: # 门禁,ADR-0015)此前不在组织地图——门禁自身逃逸治理地图 policies: [agent_runtime] + - name: archive + layer: L1 + visibility: public + status: active + role: 记忆层——ADR 归档正本(append-only,字节保真)+规划回归集+事件 JSONL+红队报告 + 落位(宪法 §1/§13 推论二,W1-C1 .github#164/ADR-0053);ADR 状态真源=agent-registry + decisions/INDEX.yaml 墓碑索引(ADR-0053) + key_paths: [adr/, scripts/verify_migration.py] + - name: template-service layer: L2 visibility: public diff --git a/governance/drift-check.sh b/governance/drift-check.sh index 231c3f0..c1ca391 100644 --- a/governance/drift-check.sh +++ b/governance/drift-check.sh @@ -284,11 +284,15 @@ for r in $REPOS; do done # ---------- 10. ADR 引用存在性+实体性后验(adr-required 的补充防线,评审项 + RB-D5)---------- -# gate.yml 的 adr-required 在 PR 上下文只能做语法检查:agent-registry 是私有仓, -# PR 上下文的 GITHUB_TOKEN 无跨仓读权,注入 org secret 又会向 PR 控制的代码暴露 -# 凭据。存在性在本节后验:窗口内合并 PR 的 ADR-NNNN 引用必须真实存在于 -# agent-registry/decisions/——伪造/幽灵 ADR 最长 24h 内被检出(与 §8 直推检测 -# 同为 post-hoc 防线;C1 的权威人类门禁仍是 owner-only review)。 +# gate.yml 的 adr-required 已在 PR 时点做存在性校验(ADR-0021:全仓公开后 PR 上下文 +# GITHUB_TOKEN 可读公开仓——早期"agent-registry 是私有仓、PR 时点无跨仓读权"的前提 +# 已随 ADR-0020 全仓公开政策失效,注记修正);ADR-0053(W1-C1 .github#164)索引世界 +# 起进一步校验 archive 正本可达性。本节后验实体性(防空壳)并独立复核存在性: +# 窗口内合并 PR 的 ADR-NNNN 引用必须有实质决策结构——伪造/幽灵 ADR 最长 24h 内 +# 检出(与 §8 直推检测同为 post-hoc 防线;C1 的权威人类门禁仍是 owner-only review)。 +# 内容源双世界(ADR-0053):agent-registry/decisions/INDEX.yaml(墓碑索引)存在 → +# decisions/ 只剩同名墓碑(无实质结构),实体性改验 INDEX 指向的 archive 仓正本 +# (字节保真原件);INDEX 404(迁移前/回滚)→ 旧逻辑对 decisions/ 本体验证。 ADR_RE='ADR-[0-9]{4}' # 实体性判定(评审项:size 字节数可被空白/注释/填充绕过——100B 阈值挡不住 # RB-D5 意义上的空壳):拉取被引 ADR 全文做结构校验,H1 编号行、status 行、 @@ -299,16 +303,26 @@ declare -A ADR_VERDICT_CACHE adr_substantive() { # $1=四位编号 → stdout: missing|ok|shell|unreadable local num="$1" matches apath content decoded verdict [[ -n "${ADR_VERDICT_CACHE[$num]:-}" ]] && { echo "${ADR_VERDICT_CACHE[$num]}"; return; } - matches=$(jq -r --arg p "ADR-${num}-" '.[] | select((.name | startswith($p)) and (.type == "file")) | .path' <<<"$ADR_DIR_LISTING") + if [[ -n "${ADR_INDEX_MODE:-}" ]]; then + # 索引世界(ADR-0053):编号→archive 正本路径(可多条:ADR-0011 双档先例) + matches=$(awk -v n="$num" '$1 == n {print $2}' <<<"$ADR_INDEX_MAP") + else + matches=$(jq -r --arg p "ADR-${num}-" '.[] | select((.name | startswith($p)) and (.type == "file")) | .path' <<<"$ADR_DIR_LISTING") + fi if [[ -z "$matches" ]]; then verdict="missing" else verdict="unreadable" while IFS= read -r apath; do [[ -n "$apath" ]] || continue - content=$(api "https://api.github.com/repos/$ORG/agent-registry/contents/$apath" | jq -r '.content // empty') - [[ -z "$content" ]] && continue - decoded=$(base64 -d <<<"$content" 2>/dev/null || true) + if [[ -n "${ADR_INDEX_MODE:-}" ]]; then + # 索引世界:archive raw 正本(公开仓,字节保真原件);拉取失败留空→按不可判定处理 + decoded=$(curl -sSf --max-time 20 "https://raw.githubusercontent.com/$ORG/archive/main/${apath}" 2>/dev/null || true) + else + content=$(api "https://api.github.com/repos/$ORG/agent-registry/contents/$apath" | jq -r '.content // empty') + [[ -z "$content" ]] && continue + decoded=$(base64 -d <<<"$content" 2>/dev/null || true) + fi [[ -z "$decoded" ]] && continue if grep -qE "^#[[:space:]]*ADR-${num}([^0-9]|$)" <<<"$decoded" \ && grep -qE "^-[[:space:]]*(status|状态):[[:space:]]*[^[:space:]]" <<<"$decoded" \ @@ -330,6 +344,28 @@ ADR_DIR_LISTING=$(api "https://api.github.com/repos/$ORG/agent-registry/contents if ! jq -e 'type == "array"' <<<"$ADR_DIR_LISTING" >/dev/null 2>&1; then drift "ADR 真源 agent-registry/decisions 读取失败,引用存在性无法后验(fail-closed): $(jq -r '.message // "非数组"' <<<"$ADR_DIR_LISTING" 2>/dev/null || echo 传输失败)" else + # 墓碑索引探测(ADR-0053):200 → 索引世界(map:编号→archive 正本路径); + # 404 → 旧世界;其他失败 → 报漂移(fail-closed:检测器失明不得伪装无漂移), + # 并降级旧世界继续跑完本节(漂移行已置红,后续节不受影响)。 + ADR_INDEX_MODE="" + ADR_INDEX_MAP="" + ADR_INDEX_JSON=$(api "https://api.github.com/repos/$ORG/agent-registry/contents/decisions/INDEX.yaml") + if jq -e '.content' <<<"$ADR_INDEX_JSON" >/dev/null 2>&1; then + ADR_INDEX_MAP=$(base64 -d <<<"$(jq -r '.content' <<<"$ADR_INDEX_JSON")" 2>/dev/null | awk ' + /^[[:space:]]*-[[:space:]]*number:/ { n=$3; gsub(/[^0-9]/, "", n); cur=sprintf("%04d", n+0); ap="" } + /^[[:space:]]*archive_path:/ { + line=$0; sub(/^[[:space:]]*archive_path:[[:space:]]*/, "", line); gsub(/"/, "", line) + if (cur != "" && line != "") { print cur " " line; cur="" } + }') + if [[ -n "$ADR_INDEX_MAP" ]] && grep -qE '^[0-9]{4} adr/.+\.md$' <<<"$ADR_INDEX_MAP"; then + ADR_INDEX_MODE=1 + ok "adr-index(ADR-0053 索引世界:$(wc -l <<<"$ADR_INDEX_MAP") entries,实体性改验 archive 正本)" + else + drift "decisions/INDEX.yaml 存在但解析为空/畸形——索引世界不可判定(fail-closed,按旧世界降级继续)" + fi + elif ! grep -q '"message"[[:space:]]*:[[:space:]]*"Not Found"' <<<"$ADR_INDEX_JSON"; then + drift "decisions/INDEX.yaml 探测失败(非 404 的 API 错误)——索引世界判定失败(fail-closed,按旧世界降级继续)" + fi ADR_FILES=$(jq -r '.[].name' <<<"$ADR_DIR_LISTING") GHOST=0 for r in $REPOS; do @@ -347,10 +383,15 @@ else case "$(adr_substantive "$num")" in ok) : ;; missing) - drift "repo '$r' PR#$pnum 引用幽灵 ADR ${ref}(agent-registry/decisions/ 无 ADR-${num}-*.md——C1 变更的决策背书不成立)" + if [[ -n "$ADR_INDEX_MODE" ]]; then + where="INDEX entries 无此编号" + else + where="agent-registry/decisions/ 无 ADR-${num}-*.md" + fi + drift "repo '$r' PR#$pnum 引用幽灵 ADR ${ref}(${where}——C1 变更的决策背书不成立)" GHOST=1 ;; shell) - drift "repo '$r' PR#$pnum 引用空壳 ADR ${ref}(文件存在但缺实质结构:H1 编号行/status/背景/决策章节须齐备且决策节有正文——红队 RB-D5:字节数填充不再能绕过)" + drift "repo '$r' PR#$pnum 引用空壳 ADR ${ref}(正本存在但缺实质结构:H1 编号行/status/背景/决策章节须齐备且决策节有正文——红队 RB-D5:字节数填充不再能绕过)" GHOST=1 ;; unreadable) drift "repo '$r' PR#$pnum 引用的 ADR ${ref} 全部同名文件内容读取失败,实体性无法判定(fail-closed)" diff --git a/governance/expected-state.json b/governance/expected-state.json index fe55550..e0ff813 100644 --- a/governance/expected-state.json +++ b/governance/expected-state.json @@ -118,7 +118,7 @@ ] }, "direct_push_exemptions": { - "comment": "§8 直推检测豁免——两类,均须 ADR 背书,逐完整 SHA 登记(新直推不可能搭便车):(a) 破玻璃直推回填:GM-2 破玻璃的机器可读回填形式(ADR-0016 附录),事件定性见 ADR-0017(.github 两条:净变更仅为 Trae IDE 分享 zip 与误入 gitlink,彼时 PR 流程已运转);(b) 建仓 bootstrap 初始 commit(ADR-0021):仓库创建时序上分支/PR 尚不存在、不可能走 PR——Shorts_Director/agent-tools/Script_Writer 的 Initial commit 类(含 GitHub UI 建仓附带的 README 编辑 commit),登记为豁免而非追认破玻璃;arbiter 建仓 bootstrap 同理(W1-C2 .github#165/ADR-0054)。", + "comment": "§8 直推检测豁免——两类,均须 ADR 背书,逐完整 SHA 登记(新直推不可能搭便车):(a) 破玻璃直推回填:GM-2 破玻璃的机器可读回填形式(ADR-0016 附录),事件定性见 ADR-0017(.github 两条:净变更仅为 Trae IDE 分享 zip 与误入 gitlink,彼时 PR 流程已运转);(b) 建仓 bootstrap 初始 commit(ADR-0021):仓库创建时序上分支/PR 尚不存在、不可能走 PR——Shorts_Director/agent-tools/Script_Writer 的 Initial commit 类(含 GitHub UI 建仓附带的 README 编辑 commit),登记为豁免而非追认破玻璃;arbiter 建仓 bootstrap 同理(W1-C2 .github#165/ADR-0054)。archive(W1-C1 .github#164/ADR-0053):bootstrap README commit 同属 (b) 类——注意 ADR-0046 后 org-required-workflows ruleset 无 bypass,空仓首推被 required workflow 拦截,豁免操作=对 org ruleset 的 archive 仓库做秒级临时 exclude 后经 contents API 建 commit 并即刻还原(全程约 5 秒,已留痕于 #164);ruleset 最终态与落盘定义一致(drift-check §1 不受影响)。", ".github": [ "9b056b3a925038484a8f6655ea45f81f071d5df6", "416f5f57fd459f6ece3587f3aa20b772148ad335" @@ -153,6 +153,9 @@ ], "arbiter": [ "7b1a65234b1697bde11cea2ebdacd8ee0efc44c4" + ], + "archive": [ + "a32f0fcea2bc7b422ad8ad1463a8ad4a92cbeb60" ] }, "merge_queue": { @@ -180,4 +183,4 @@ "ref": "refs/tags/v1.4.2", "ref_commit": "2d368c29f170eab43b83f6b866f76fad3774fba9" } -} \ No newline at end of file +}