From aac467ffb731f795f33b252c7e31db21a93b9bd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:05:17 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20=E5=A4=A9=E7=9C=BCPR?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E9=A3=8E=E9=99=A9=E6=A3=80=E6=9F=A5=E5=BC=95?= =?UTF-8?q?=E6=93=8E=20=E2=80=94=20=E5=BC=A5=E8=A1=A5PR=E7=BA=A7=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com> Agent-Logs-Url: https://github.com/qinfendebingshuo/guanghulab/sessions/fbf443ce-2f3e-4a23-a3e4-515cc2dda5a7 --- .github/persona-brain/agent-registry.json | 12 +- .github/workflows/skyeye-pr-risk-check.yml | 120 ++++++ .github/workflows/zhuyuan-pr-review.yml | 1 - scripts/skyeye/pr-risk-check.js | 422 +++++++++++++++++++++ 4 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/skyeye-pr-risk-check.yml create mode 100644 scripts/skyeye/pr-risk-check.js diff --git a/.github/persona-brain/agent-registry.json b/.github/persona-brain/agent-registry.json index c98aa005..1aa5c239 100644 --- a/.github/persona-brain/agent-registry.json +++ b/.github/persona-brain/agent-registry.json @@ -1217,6 +1217,16 @@ "owner": "ICE-0002∞", "registered": "2026-03-25", "description": "每次系统执行后自动更新仪表盘数据 + README + GitHub Pages 实时仪表盘" + }, + { + "id": "AG-ZY-095", + "name": "PR合并风险检查引擎", + "type": "workflow", + "file": ".github/workflows/skyeye-pr-risk-check.yml", + "parent_sys": "SYS-GLW-0001", + "owner": "ICE-0002∞", + "registered": "2026-03-25", + "description": "天眼系统PR级合并前风险评估 — R1关键文件覆盖/R2受保护路径入侵/R3构建产物/R4权限/R5大规模删除" } ], "note": "铸渊已扫描所有workflow文件,按此格式逐一注册,编号从AG-ZY-001开始顺序分配", @@ -1234,4 +1244,4 @@ "registered_by": "TCS-0002∞" } } -} +} \ No newline at end of file diff --git a/.github/workflows/skyeye-pr-risk-check.yml b/.github/workflows/skyeye-pr-risk-check.yml new file mode 100644 index 00000000..8cefe905 --- /dev/null +++ b/.github/workflows/skyeye-pr-risk-check.yml @@ -0,0 +1,120 @@ +# ═══════════════════════════════════════════════ +# 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001 +# 📜 Copyright: 国作登字-2026-A-00037559 +# ═══════════════════════════════════════════════ +# +# 天眼 · PR 合并风险检查 +# SkyEye PR Merge Risk Check +# +# 弥补天眼系统在 PR 合并环节的安全缺口: +# gate-guard v1/v2 保护 push 事件 +# 本工作流保护 pull_request 事件(合并前风险评估) +# +# 风险检测维度: +# R1 · 关键文件覆盖检测(docs/index.html 等) +# R2 · 受保护路径入侵检测 +# R3 · 构建产物误入检测 +# R4 · 开发者路径权限检测 +# R5 · 大规模删除检测 +# +name: 天眼 · PR风险检查 + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + risk-check: + name: 🛡️ PR合并风险评估 + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Collect PR changed files + env: + GH_TOKEN: ${{ github.token }} + run: | + # Get the list of files changed in this PR + gh pr diff ${{ github.event.pull_request.number }} --name-only > /tmp/pr_files.txt || true + + # Get diff stats + gh pr diff ${{ github.event.pull_request.number }} --stat > /tmp/pr_stats_raw.txt || true + + # Parse stats into numstat-like format for the script + # Extract additions/deletions from the stat output + git diff --numstat origin/${{ github.base_ref }}...origin/${{ github.head_ref }} > /tmp/pr_stats.txt 2>/dev/null || true + + echo "📂 Changed files:" + cat /tmp/pr_files.txt + echo "" + echo "📊 Stats:" + cat /tmp/pr_stats.txt + + - name: 🔍 天眼 PR 风险检查 + id: risk_check + env: + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_FILES: /tmp/pr_files.txt + PR_STATS: /tmp/pr_stats.txt + run: node scripts/skyeye/pr-risk-check.js + + - name: 📝 Write risk report comment + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const decision = '${{ steps.risk_check.outcome }}'; + const riskLevel = '${{ steps.risk_check.outputs.risk_level }}' || 'unknown'; + const riskSummary = '${{ steps.risk_check.outputs.risk_summary }}' || ''; + const author = '${{ github.event.pull_request.user.login }}'; + + let body = '## 🛡️ 天眼 · PR合并风险检查报告\n\n'; + body += `**PR作者:** ${author}\n`; + body += `**检查时间:** ${new Date().toISOString()}\n\n`; + + if (decision === 'success') { + body += '### ✅ 风险检查通过\n\n'; + body += `风险等级: **${riskLevel}**\n\n`; + if (riskSummary && riskSummary !== '仓库主人 PR,直接放行' && riskSummary !== '白名单用户 PR,直接放行') { + body += '> ⚠️ 注意事项:\n'; + body += `> ${riskSummary}\n\n`; + } + body += '此 PR 可以安全合并。\n'; + } else { + body += '### ❌ 风险检查未通过\n\n'; + body += `风险等级: **${riskLevel}**\n\n`; + body += '**检测到的风险:**\n'; + if (riskSummary) { + const items = riskSummary.split(' | '); + for (const item of items) { + body += `- 🔴 ${item}\n`; + } + } + body += '\n⛔ **此 PR 已被天眼系统阻止合并。**\n'; + body += '请联系仓库管理员 (TCS-0002∞) 审核。\n'; + } + + body += '\n---\n'; + body += '> 天眼 · PR合并风险检查引擎 · SkyEye PR Risk Check'; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); diff --git a/.github/workflows/zhuyuan-pr-review.yml b/.github/workflows/zhuyuan-pr-review.yml index 0dc9fbd2..64abe557 100644 --- a/.github/workflows/zhuyuan-pr-review.yml +++ b/.github/workflows/zhuyuan-pr-review.yml @@ -27,7 +27,6 @@ jobs: - name: Run contract check id: contract - continue-on-error: true run: node scripts/contract-check.js 2>&1 | tee /tmp/contract-result.txt - name: Run route alignment diff --git a/scripts/skyeye/pr-risk-check.js b/scripts/skyeye/pr-risk-check.js new file mode 100644 index 00000000..5c9bb6b3 --- /dev/null +++ b/scripts/skyeye/pr-risk-check.js @@ -0,0 +1,422 @@ +#!/usr/bin/env node +// scripts/skyeye/pr-risk-check.js +// 天眼·PR合并风险检查引擎 +// +// ═══════════════════════════════════════════════ +// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001 +// 📜 Copyright: 国作登字-2026-A-00037559 +// ═══════════════════════════════════════════════ +// +// 目的:弥补天眼系统在PR合并环节的安全缺口 +// gate-guard v1/v2 保护 push 事件 +// 本脚本保护 pull_request 事件(合并前风险评估) +// +// 核心原则(与 gate-guard 一致): +// 仓库主人(repo_owner)的 PR → 永远放行 +// 系统 bot PR → 永远放行 +// 其他 PR → 按风险等级评估 +// +// 风险检测维度: +// R1 · 关键文件覆盖检测(docs/index.html 等) +// R2 · 受保护路径入侵检测 +// R3 · 构建产物误入检测 +// R4 · 开发者路径权限检测 +// R5 · 大规模删除检测 +// +// 输入环境变量: +// PR_AUTHOR — PR 作者 GitHub username +// PR_FILES — 变更文件列表路径(默认 /tmp/pr_files.txt) +// PR_STATS — 变更统计路径(默认 /tmp/pr_stats.txt) +// +// 输出: +// exit 0 → pass(允许合并) +// exit 1 → block(阻止合并) +// GITHUB_OUTPUT → risk_level, risk_summary, decision + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// ━━━ 配置路径 ━━━ +const ROOT = path.resolve(__dirname, '../..'); +const BRAIN_CONFIG_PATH = path.join(ROOT, '.github/persona-brain/gate-guard-config.json'); +const OWNER_CONFIG_PATH = path.join(ROOT, '.github/gate-guard-config.json'); +const PR_FILES_PATH = process.env.PR_FILES || '/tmp/pr_files.txt'; +const PR_STATS_PATH = process.env.PR_STATS || '/tmp/pr_stats.txt'; +const GITHUB_OUTPUT = process.env.GITHUB_OUTPUT || '/dev/null'; + +// ━━━ 仓库主人 ━━━ +const REPO_OWNER = 'qinfendebingshuo'; + +// ━━━ 关键文件 — 这些文件被覆盖意味着高风险 ━━━ +const CRITICAL_FILES = [ + 'docs/index.html', + 'docs/CNAME', + 'docs/.nojekyll', + 'README.md', + 'package.json', + 'package-lock.json', + '.github/persona-brain/security-protocol.json', + '.github/persona-brain/gate-guard-config.json', + '.github/gate-guard-config.json' +]; + +// ━━━ 构建产物特征 — 这些模式不应出现在仓库中 ━━━ +const BUILD_ARTIFACT_PATTERNS = [ + /^docs\/assets\/index-[A-Za-z0-9_-]+\.(js|css)$/, // Vite build hashes + /^dist\//, // dist directory + /^build\//, // build directory + /^\.next\//, // Next.js build + /^node_modules\//, // node_modules + /\.chunk\.(js|css)$/, // Webpack chunks + /\.bundle\.(js|css)$/ // Bundle files +]; + +// ━━━ 大规模删除阈值 ━━━ +const MASS_DELETE_THRESHOLD = 500; // lines + +// ━━━ 安全读取 JSON ━━━ +function readJSON(filePath) { + try { + if (!fs.existsSync(filePath)) return null; + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (e) { + console.error(`⚠️ 无法读取 ${path.basename(filePath)}: ${e.message}`); + return null; + } +} + +// ━━━ 输出到 GITHUB_OUTPUT ━━━ +function setOutput(key, value) { + try { + fs.appendFileSync(GITHUB_OUTPUT, `${key}=${value}\n`); + } catch (e) { + // silent fail for local testing + } +} + +// ━━━ 加载门禁配置 ━━━ +function loadConfig() { + const brainConfig = readJSON(BRAIN_CONFIG_PATH); + const ownerConfig = readJSON(OWNER_CONFIG_PATH); + + if (!brainConfig && !ownerConfig) { + console.error('⚠️ 门禁配置缺失,使用最小安全配置'); + return { + whitelist: [REPO_OWNER, 'github-actions[bot]', 'zhuyuan-bot'], + system_protected_paths: ['.github/', 'scripts/', 'docs/', 'data/', 'core/', 'connectors/'], + developers: {} + }; + } + + return { + whitelist: brainConfig?.whitelist_actors || ownerConfig?.whitelist || [], + system_protected_paths: brainConfig?.system_protected_paths || ownerConfig?.protected_paths || [], + developers_brain: brainConfig?.developer_permissions || {}, + developers_owner: ownerConfig?.developers || {} + }; +} + +// ━━━ 读取 PR 变更文件列表 ━━━ +function loadPRFiles() { + try { + if (!fs.existsSync(PR_FILES_PATH)) { + console.warn('⚠️ PR文件列表不存在: ' + PR_FILES_PATH); + return []; + } + return fs.readFileSync(PR_FILES_PATH, 'utf8') + .split('\n') + .map(f => f.trim()) + .filter(f => f.length > 0); + } catch (e) { + console.error('⚠️ 无法读取PR文件列表: ' + e.message); + return []; + } +} + +// ━━━ 读取 PR 变更统计 ━━━ +function loadPRStats() { + try { + if (!fs.existsSync(PR_STATS_PATH)) return null; + const content = fs.readFileSync(PR_STATS_PATH, 'utf8').trim(); + // Format: "additions\tdeletions\tfilename" per line + let totalDeletions = 0; + for (const line of content.split('\n')) { + const parts = line.split('\t'); + if (parts.length >= 2) { + const del = parseInt(parts[1], 10); + if (!isNaN(del)) totalDeletions += del; + } + } + return { totalDeletions }; + } catch (e) { + return null; + } +} + +// ━━━ 识别开发者身份 ━━━ +function identifyDeveloper(author, config) { + // Check brain config + if (config.developers_brain) { + for (const [devId, dev] of Object.entries(config.developers_brain)) { + if (dev.github_usernames && dev.github_usernames.includes(author)) { + return { devId, name: dev.name, allowed_paths: dev.allowed_paths || [] }; + } + } + } + + // Check owner config + if (config.developers_owner && config.developers_owner[author]) { + const dev = config.developers_owner[author]; + return { devId: dev.dev_id, name: dev.name, allowed_paths: dev.allowed_paths || [] }; + } + + return null; +} + +// ━━━ R1 · 关键文件覆盖检测 ━━━ +function checkCriticalFiles(files) { + const risks = []; + for (const file of files) { + if (CRITICAL_FILES.includes(file)) { + risks.push({ + dimension: 'R1', + severity: 'high', + file, + detail: `关键文件被修改: ${file}` + }); + } + } + return risks; +} + +// ━━━ R2 · 受保护路径入侵检测 ━━━ +function checkProtectedPaths(files, config, developer) { + const risks = []; + + // If developer is registered, check if they're modifying protected paths outside their allowed_paths + if (developer) { + for (const file of files) { + const isProtected = config.system_protected_paths.some(p => file.startsWith(p)); + if (!isProtected) continue; + + const isAllowed = developer.allowed_paths.some(p => file.startsWith(p)); + if (!isAllowed) { + risks.push({ + dimension: 'R2', + severity: 'high', + file, + detail: `${developer.name}(${developer.devId}) 修改了授权路径之外的受保护文件: ${file}` + }); + } + } + } else { + // Unknown developer touching protected paths + for (const file of files) { + const isProtected = config.system_protected_paths.some(p => file.startsWith(p)); + if (isProtected) { + risks.push({ + dimension: 'R2', + severity: 'critical', + file, + detail: `未注册开发者修改受保护路径: ${file}` + }); + } + } + } + + return risks; +} + +// ━━━ R3 · 构建产物误入检测 ━━━ +function checkBuildArtifacts(files) { + const risks = []; + for (const file of files) { + for (const pattern of BUILD_ARTIFACT_PATTERNS) { + if (pattern.test(file)) { + risks.push({ + dimension: 'R3', + severity: 'high', + file, + detail: `构建产物不应提交到仓库: ${file}` + }); + break; + } + } + } + return risks; +} + +// ━━━ R4 · 开发者路径权限检测 ━━━ +function checkDeveloperPaths(files, developer) { + if (!developer) return []; // Handled by R2 + + const risks = []; + for (const file of files) { + const isAllowed = developer.allowed_paths.some(p => file.startsWith(p)); + if (!isAllowed) { + // Not in allowed paths — but might be in a non-protected area (info level) + risks.push({ + dimension: 'R4', + severity: 'medium', + file, + detail: `${developer.name}(${developer.devId}) 修改了非授权路径: ${file}` + }); + } + } + return risks; +} + +// ━━━ R5 · 大规模删除检测 ━━━ +function checkMassDeletion(stats) { + const risks = []; + if (stats && stats.totalDeletions > MASS_DELETE_THRESHOLD) { + risks.push({ + dimension: 'R5', + severity: 'high', + file: '(multiple)', + detail: `大规模删除检测: ${stats.totalDeletions} 行被删除(阈值: ${MASS_DELETE_THRESHOLD})` + }); + } + return risks; +} + +// ━━━ 风险评估决策 ━━━ +function makeDecision(risks) { + const hasCritical = risks.some(r => r.severity === 'critical'); + const highCount = risks.filter(r => r.severity === 'high').length; + const mediumCount = risks.filter(r => r.severity === 'medium').length; + + if (hasCritical) { + return { decision: 'block', risk_level: 'critical' }; + } + if (highCount >= 2) { + return { decision: 'block', risk_level: 'high' }; + } + if (highCount === 1) { + return { decision: 'warn', risk_level: 'high' }; + } + if (mediumCount >= 3) { + return { decision: 'warn', risk_level: 'medium' }; + } + return { decision: 'pass', risk_level: 'low' }; +} + +// ━━━ 主逻辑 ━━━ +function run() { + const author = process.env.PR_AUTHOR || ''; + console.log('═══════════════════════════════════════════════'); + console.log('🔺 天眼 · PR合并风险检查引擎'); + console.log('═══════════════════════════════════════════════'); + console.log(`PR作者: ${author || '(unknown)'}`); + console.log(`时间: ${new Date().toISOString()}`); + console.log(''); + + // ─── 白名单检查 ─── + if (author === REPO_OWNER) { + console.log('✅ 仓库主人 PR — 直接放行'); + setOutput('decision', 'pass'); + setOutput('risk_level', 'none'); + setOutput('risk_summary', '仓库主人 PR,直接放行'); + process.exit(0); + } + + const config = loadConfig(); + + if (config.whitelist.includes(author)) { + console.log(`✅ 白名单用户 (${author}) — 直接放行`); + setOutput('decision', 'pass'); + setOutput('risk_level', 'none'); + setOutput('risk_summary', '白名单用户 PR,直接放行'); + process.exit(0); + } + + // ─── 加载变更文件 ─── + const files = loadPRFiles(); + if (files.length === 0) { + console.log('⚠️ 无变更文件或文件列表不可用 — 放行'); + setOutput('decision', 'pass'); + setOutput('risk_level', 'unknown'); + setOutput('risk_summary', '无变更文件信息'); + process.exit(0); + } + + console.log(`📂 变更文件数: ${files.length}`); + files.forEach(f => console.log(` · ${f}`)); + console.log(''); + + // ─── 识别开发者 ─── + const developer = identifyDeveloper(author, config); + if (developer) { + console.log(`👤 识别开发者: ${developer.name} (${developer.devId})`); + } else { + console.log(`⚠️ 未注册开发者: ${author}`); + } + console.log(''); + + // ─── 加载统计 ─── + const stats = loadPRStats(); + + // ─── 风险检测 ─── + console.log('━━━ 风险检测开始 ━━━'); + const allRisks = [ + ...checkCriticalFiles(files), + ...checkProtectedPaths(files, config, developer), + ...checkBuildArtifacts(files), + ...checkDeveloperPaths(files, developer), + ...checkMassDeletion(stats) + ]; + + // Deduplicate by file + dimension + const seen = new Set(); + const risks = allRisks.filter(r => { + const key = `${r.dimension}:${r.file}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + if (risks.length === 0) { + console.log('✅ 未检测到风险'); + console.log(''); + console.log('═══ 决策: PASS ═══'); + setOutput('decision', 'pass'); + setOutput('risk_level', 'low'); + setOutput('risk_summary', '未检测到风险,允许合并'); + process.exit(0); + } + + // ─── 输出风险报告 ─── + console.log(''); + console.log(`⚠️ 检测到 ${risks.length} 项风险:`); + for (const risk of risks) { + const icon = risk.severity === 'critical' ? '🔴' : risk.severity === 'high' ? '🟠' : '🟡'; + console.log(` ${icon} [${risk.dimension}] ${risk.detail}`); + } + console.log(''); + + // ─── 决策 ─── + const { decision, risk_level } = makeDecision(risks); + const summary = risks.map(r => `[${r.dimension}] ${r.detail}`).join(' | '); + + setOutput('decision', decision); + setOutput('risk_level', risk_level); + setOutput('risk_summary', summary.slice(0, 500)); + + if (decision === 'block') { + console.log(`═══ 决策: BLOCK (风险等级: ${risk_level}) ═══`); + console.log('❌ PR被阻止合并 — 请联系仓库管理员审核'); + process.exit(1); + } else if (decision === 'warn') { + console.log(`═══ 决策: WARN (风险等级: ${risk_level}) ═══`); + console.log('⚠️ PR存在风险 — 建议管理员审核后合并'); + // Warn exits with 0 to not block, but provides information + process.exit(0); + } else { + console.log(`═══ 决策: PASS (风险等级: ${risk_level}) ═══`); + process.exit(0); + } +} + +run();