297 lines
11 KiB
JavaScript
297 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
// scripts/skyeye/dev-portal-guard.js
|
||
// 天眼·开发者门户沙箱守卫 (Dev Portal Sandbox Guard)
|
||
//
|
||
// ═══════════════════════════════════════════════
|
||
// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
|
||
// 📜 Copyright: 国作登字-2026-A-00037559
|
||
// ═══════════════════════════════════════════════
|
||
//
|
||
// 目的:保护开发者门户的沙箱隔离
|
||
// 每位开发者只能部署到自己的频道 (channels/DEV-XXX/)
|
||
// 门户框架文件 (index.html, manifest.json) 受保护
|
||
// 必须提供完整身份三要素:开发者编号 + 姓名 + 人格体编号
|
||
// 匿名代理(无身份信息)= 禁止部署到任何真实路径
|
||
//
|
||
// 输入环境变量:
|
||
// PR_BRANCH — 分支名
|
||
// PR_TITLE — PR 标题
|
||
// PR_COMMITS — Commit 信息路径
|
||
// PR_FILES — 变更文件列表路径
|
||
// NOTION_API_KEY — Notion API 密钥(可选,用于跟踪)
|
||
// DEV_PROFILE_DB_ID — 开发者画像数据库 ID(可选)
|
||
//
|
||
// 输出:
|
||
// exit 0 → 允许部署
|
||
// exit 1 → 禁止部署
|
||
// GITHUB_OUTPUT → decision, dev_id, dev_name, persona_id, channel_files
|
||
|
||
'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 MANIFEST_PATH = path.join(ROOT, 'docs/dev-portal/manifest.json');
|
||
const PR_FILES_PATH = process.env.PR_FILES || '/tmp/pr_files.txt';
|
||
const PR_COMMITS_PATH = process.env.PR_COMMITS || '/tmp/pr_commits.txt';
|
||
const GITHUB_OUTPUT = process.env.GITHUB_OUTPUT || '/dev/null';
|
||
|
||
// ━━━ 门户保护文件 — 开发者不可修改 ━━━
|
||
const PORTAL_FRAMEWORK_FILES = [
|
||
'docs/dev-portal/index.html',
|
||
'docs/dev-portal/manifest.json'
|
||
];
|
||
|
||
// ━━━ 安全读取 JSON ━━━
|
||
function readJSON(filePath) {
|
||
try {
|
||
if (!fs.existsSync(filePath)) return null;
|
||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function setOutput(key, value) {
|
||
try {
|
||
fs.appendFileSync(GITHUB_OUTPUT, `${key}=${value}\n`);
|
||
} catch (e) { /* silent */ }
|
||
}
|
||
|
||
// ━━━ 从 PR 元数据中提取身份三要素 ━━━
|
||
function extractIdentity(prTitle, commitsPath) {
|
||
const identity = {
|
||
devId: null,
|
||
devName: null,
|
||
personaId: null,
|
||
complete: false,
|
||
source: []
|
||
};
|
||
|
||
// From PR title
|
||
if (prTitle) {
|
||
const devMatch = prTitle.match(/\b(DEV-\d{3})\b/i);
|
||
if (devMatch) { identity.devId = devMatch[1].toUpperCase(); identity.source.push('pr_title'); }
|
||
}
|
||
|
||
// From commit messages
|
||
try {
|
||
if (fs.existsSync(commitsPath)) {
|
||
const content = fs.readFileSync(commitsPath, 'utf8');
|
||
|
||
// DEV-XXX
|
||
if (!identity.devId) {
|
||
const devMatch = content.match(/\b(DEV-\d{3})\b/i);
|
||
if (devMatch) { identity.devId = devMatch[1].toUpperCase(); identity.source.push('commit_msg'); }
|
||
}
|
||
|
||
// Persona signature [PER-XXX]
|
||
const perMatch = content.match(/\[(PER-\d{3})\]/);
|
||
if (perMatch) { identity.personaId = perMatch[1]; identity.source.push('commit_sig'); }
|
||
|
||
// TCS-GL-XX format
|
||
const tcsMatch = content.match(/\b(TCS-GL-\d{2})\b/i);
|
||
if (tcsMatch && !identity.devId) { identity.devId = tcsMatch[1].toUpperCase(); identity.source.push('tcs_ref'); }
|
||
}
|
||
} catch (e) { /* non-fatal */ }
|
||
|
||
// From changed file paths — infer DEV-XXX from channel paths
|
||
try {
|
||
if (fs.existsSync(PR_FILES_PATH)) {
|
||
const files = fs.readFileSync(PR_FILES_PATH, 'utf8').split('\n').filter(Boolean);
|
||
for (const file of files) {
|
||
const channelMatch = file.match(/^docs\/dev-portal\/channels\/(DEV-\d{3})\//);
|
||
if (channelMatch) {
|
||
if (!identity.devId) {
|
||
identity.devId = channelMatch[1];
|
||
identity.source.push('file_path');
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { /* non-fatal */ }
|
||
|
||
// Cross-reference with gate-guard config for name and persona_id
|
||
if (identity.devId) {
|
||
const config = readJSON(BRAIN_CONFIG_PATH);
|
||
if (config && config.developer_permissions && config.developer_permissions[identity.devId]) {
|
||
const dev = config.developer_permissions[identity.devId];
|
||
identity.devName = dev.name;
|
||
if (!identity.personaId) identity.personaId = dev.persona_id;
|
||
}
|
||
}
|
||
|
||
// Completeness check: need all three (devId + devName + personaId)
|
||
identity.complete = !!(identity.devId && identity.devName && identity.personaId);
|
||
|
||
return identity;
|
||
}
|
||
|
||
// ━━━ 读取变更文件列表 ━━━
|
||
function loadPRFiles() {
|
||
try {
|
||
if (!fs.existsSync(PR_FILES_PATH)) return [];
|
||
return fs.readFileSync(PR_FILES_PATH, 'utf8')
|
||
.split('\n')
|
||
.map(f => f.trim())
|
||
.filter(f => f.length > 0);
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ━━━ 分类文件 ━━━
|
||
function classifyFiles(files) {
|
||
const result = {
|
||
portalFramework: [], // 门户框架文件(保护)
|
||
ownChannel: [], // 自己频道的文件
|
||
otherChannels: [], // 他人频道的文件
|
||
outsidePortal: [], // 门户以外的文件
|
||
channelDevIds: new Set() // 涉及的频道编号
|
||
};
|
||
|
||
for (const file of files) {
|
||
if (PORTAL_FRAMEWORK_FILES.includes(file)) {
|
||
result.portalFramework.push(file);
|
||
} else if (file.startsWith('docs/dev-portal/channels/')) {
|
||
const channelMatch = file.match(/^docs\/dev-portal\/channels\/(DEV-\d{3})\//);
|
||
if (channelMatch) {
|
||
result.channelDevIds.add(channelMatch[1]);
|
||
result.ownChannel.push(file); // Will be re-classified after identity check
|
||
}
|
||
} else if (file.startsWith('docs/dev-portal/')) {
|
||
result.portalFramework.push(file); // Any other dev-portal file is framework
|
||
} else {
|
||
result.outsidePortal.push(file);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// ━━━ 主逻辑 ━━━
|
||
function run() {
|
||
const branch = process.env.PR_BRANCH || '';
|
||
const prTitle = process.env.PR_TITLE || '';
|
||
|
||
console.log('═══════════════════════════════════════════════');
|
||
console.log('🛡️ 天眼 · 开发者门户沙箱守卫');
|
||
console.log('═══════════════════════════════════════════════');
|
||
console.log(`分支: ${branch}`);
|
||
console.log(`标题: ${prTitle}`);
|
||
console.log(`时间: ${new Date().toISOString()}`);
|
||
console.log('');
|
||
|
||
// ─── 提取身份三要素 ───
|
||
console.log('━━━ 身份验证 ━━━');
|
||
const identity = extractIdentity(prTitle, PR_COMMITS_PATH);
|
||
|
||
if (identity.devId) console.log(` 开发者编号: ${identity.devId}`);
|
||
if (identity.devName) console.log(` 开发者姓名: ${identity.devName}`);
|
||
if (identity.personaId) console.log(` 人格体编号: ${identity.personaId}`);
|
||
console.log(` 身份来源: ${identity.source.join(', ') || '无'}`);
|
||
console.log(` 三要素完整: ${identity.complete ? '✅ 是' : '❌ 否'}`);
|
||
console.log('');
|
||
|
||
// ─── 匿名屏蔽 ───
|
||
if (!identity.complete) {
|
||
console.log('❌ 身份验证失败 — 匿名代理禁止部署');
|
||
console.log('');
|
||
console.log(' 缺失信息:');
|
||
if (!identity.devId) console.log(' · 开发者编号 (DEV-XXX)');
|
||
if (!identity.devName) console.log(' · 开发者姓名');
|
||
if (!identity.personaId) console.log(' · 人格体编号 (PER-XXX)');
|
||
console.log('');
|
||
console.log(' 提示: 在 PR 标题或 commit 信息中包含 DEV-XXX 和 [PER-XXX]');
|
||
console.log(' 例: "DEV-002: deploy login module" 或 "[PER-002] feat: add feature"');
|
||
setOutput('decision', 'block');
|
||
setOutput('block_reason', 'anonymous_agent');
|
||
process.exit(1);
|
||
}
|
||
|
||
// ─── 分类变更文件 ───
|
||
const files = loadPRFiles();
|
||
if (files.length === 0) {
|
||
console.log('⚠️ 无变更文件 — 放行');
|
||
setOutput('decision', 'pass');
|
||
setOutput('dev_id', identity.devId);
|
||
process.exit(0);
|
||
}
|
||
|
||
const classified = classifyFiles(files);
|
||
const portalFiles = files.filter(f => f.startsWith('docs/dev-portal/'));
|
||
|
||
if (portalFiles.length === 0) {
|
||
console.log('ℹ️ 此 PR 不涉及门户文件 — 跳过门户守卫');
|
||
setOutput('decision', 'skip');
|
||
setOutput('dev_id', identity.devId);
|
||
process.exit(0);
|
||
}
|
||
|
||
console.log('━━━ 文件分类 ━━━');
|
||
console.log(` 门户框架文件: ${classified.portalFramework.length}`);
|
||
classified.portalFramework.forEach(f => console.log(` 🔒 ${f}`));
|
||
console.log(` 频道文件: ${classified.ownChannel.length}`);
|
||
console.log(` 涉及频道: ${[...classified.channelDevIds].join(', ') || '无'}`);
|
||
console.log(` 门户外文件: ${classified.outsidePortal.length}`);
|
||
console.log('');
|
||
|
||
// ─── 检查框架文件保护 ───
|
||
if (classified.portalFramework.length > 0) {
|
||
console.log('❌ 禁止修改门户框架文件:');
|
||
classified.portalFramework.forEach(f => console.log(` 🔒 ${f}`));
|
||
console.log('');
|
||
console.log(' 门户框架文件由天眼系统维护,开发者不可修改');
|
||
setOutput('decision', 'block');
|
||
setOutput('block_reason', 'framework_tampering');
|
||
setOutput('dev_id', identity.devId);
|
||
process.exit(1);
|
||
}
|
||
|
||
// ─── 检查频道隔离 ───
|
||
const channelIds = [...classified.channelDevIds];
|
||
const touchesOtherChannels = channelIds.some(id => id !== identity.devId);
|
||
|
||
if (touchesOtherChannels) {
|
||
const otherChannels = channelIds.filter(id => id !== identity.devId);
|
||
console.log('❌ 频道隔离违规 — 修改了他人频道:');
|
||
otherChannels.forEach(id => console.log(` 🚫 ${id} (不属于 ${identity.devId})`));
|
||
console.log('');
|
||
console.log(' 每位开发者只能部署到自己的频道沙箱');
|
||
setOutput('decision', 'block');
|
||
setOutput('block_reason', 'channel_isolation');
|
||
setOutput('dev_id', identity.devId);
|
||
process.exit(1);
|
||
}
|
||
|
||
// ─── 验证频道归属 ───
|
||
if (channelIds.length === 1 && channelIds[0] === identity.devId) {
|
||
const channelFiles = classified.ownChannel;
|
||
console.log(`✅ 频道验证通过 — ${identity.devName}(${identity.devId}) → channels/${identity.devId}/`);
|
||
console.log(` 部署文件: ${channelFiles.length}`);
|
||
channelFiles.forEach(f => console.log(` 📦 ${f}`));
|
||
console.log('');
|
||
|
||
setOutput('decision', 'pass');
|
||
setOutput('dev_id', identity.devId);
|
||
setOutput('dev_name', identity.devName);
|
||
setOutput('persona_id', identity.personaId);
|
||
setOutput('channel_files', channelFiles.join(','));
|
||
|
||
console.log('═══ 决策: PASS ═══');
|
||
console.log(` ${identity.devName} 的模块可以部署到 channels/${identity.devId}/`);
|
||
process.exit(0);
|
||
}
|
||
|
||
// Default pass for edge cases
|
||
console.log('✅ 门户守卫检查通过');
|
||
setOutput('decision', 'pass');
|
||
setOutput('dev_id', identity.devId);
|
||
process.exit(0);
|
||
}
|
||
|
||
run();
|