From 0bbf3e4b0f22086c5cd0df496f4af40c10081306 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 17 Aug 2026 20:25:54 +0800 Subject: [PATCH] feat(console): 输入输出 tab 的文件路径可点开预览;修文件端点的越界防护 --- server/routes/company.js | 53 +++++++++++++++++++++++++++++++++++------------------ server/services/graph-seed-service.js | 10 +++++++++- server/tests/unit/company-files-path-guard.test.js | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/tests/unit/graph-seed-meta-bootstrap.test.js | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/graph-console/FilePreviewModal.vue | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/graph-console/NodeDetailPanel.vue | 51 ++++++++++++++++++++++++++++++++++++++++++++++++--- web/src/views/GraphConsole.vue | 1 + 7 files changed, 546 insertions(+), 22 deletions(-) create mode 100644 server/tests/unit/company-files-path-guard.test.js create mode 100644 server/tests/unit/graph-seed-meta-bootstrap.test.js create mode 100644 web/src/components/graph-console/FilePreviewModal.vue diff --git a/server/routes/company.js b/server/routes/company.js index 9498d84..617b984 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -16,6 +16,29 @@ const companyService = new CompanyService(); const scheduleService = new ScheduleService(); /** + * 把调用方给的相对路径解析到 baseDir 之内;越界返回 null。 + * + * **不能写成 `abs.startsWith(base)`** —— 那是本文件里六处文件读写检查原先的写法, + * 它在「一个目录名是另一个的前缀」时会放行: + * base = .../data/companies/foo + * path = ../foo-evil/company.json → abs = .../data/companies/foo-evil/company.json + * abs.startsWith(base) === true → 公司 foo 读到了公司 foo-evil 的文件 + * PUT 那几处更糟:同样的缝隙上是跨公司**写**。 + * + * path.relative 才是真的在问「abs 在不在 base 里面」:越界时结果以 `..` 开头, + * 指向别的盘/绝对路径时结果是绝对路径。绝对路径入参也被这一条挡住 + * (path.resolve(base, '/etc/passwd') === '/etc/passwd' → relative 以 .. 开头)。 + */ +function resolveInside(baseDir, rel) { + const base = path.resolve(baseDir); + const abs = path.resolve(base, rel || ''); + const r = path.relative(base, abs); + if (r === '') return abs; // 指向 base 自身 + if (r.startsWith('..') || path.isAbsolute(r)) return null; + return abs; +} + +/** * fire-and-forget 触发 orggraph-planning 规划这家公司的 workflow(27-P2 §4.4)。 * 不 await —— 一轮真实 Claude CLI 对话是分钟级的,调用方必须立即返回。 * 写法与本文件 /hire 里触发 agent-creator / innovator 的自调用保持一致。 @@ -629,9 +652,8 @@ router.get('/templates/:templateId/files/content', (req, res) => { if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); - const absPath = path.resolve(DATA_DIR, req.params.templateId, filePath); - const safeBoundary = path.resolve(DATA_DIR, req.params.templateId); - if (!absPath.startsWith(safeBoundary)) { + const absPath = resolveInside(path.join(DATA_DIR, req.params.templateId), filePath); + if (!absPath) { return res.status(403).json({ error: '路径越界' }); } @@ -661,9 +683,8 @@ router.put('/templates/:templateId/files/content', (req, res) => { if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); - const absPath = path.resolve(DATA_DIR, req.params.templateId, filePath); - const safeBoundary = path.resolve(DATA_DIR, req.params.templateId); - if (!absPath.startsWith(safeBoundary)) { + const absPath = resolveInside(path.join(DATA_DIR, req.params.templateId), filePath); + if (!absPath) { return res.status(403).json({ error: '路径越界' }); } @@ -1776,9 +1797,8 @@ router.get('/:companyId/files/content', (req, res) => { if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); // 安全校验:防止路径穿越 - const absPath = path.resolve(DATA_DIR, req.params.companyId, filePath); - const safeBoundary = path.resolve(DATA_DIR, req.params.companyId); - if (!absPath.startsWith(safeBoundary)) { + const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId), filePath); + if (!absPath) { return res.status(403).json({ error: '路径越界' }); } @@ -1805,9 +1825,8 @@ router.put('/:companyId/files/content', (req, res) => { const filePath = req.body.path; if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); - const absPath = path.resolve(DATA_DIR, req.params.companyId, filePath); - const safeBoundary = path.resolve(DATA_DIR, req.params.companyId); - if (!absPath.startsWith(safeBoundary)) { + const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId), filePath); + if (!absPath) { return res.status(403).json({ error: '路径越界' }); } @@ -1881,9 +1900,8 @@ router.get('/:companyId/workspace/files/content', (req, res) => { const filePath = String(req.query.path || ''); if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); - const absPath = path.resolve(DATA_DIR, req.params.companyId, 'workspace', filePath); - const safeBoundary = path.resolve(DATA_DIR, req.params.companyId, 'workspace'); - if (!absPath.startsWith(safeBoundary)) { + const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId, 'workspace'), filePath); + if (!absPath) { return res.status(403).json({ error: '路径越界' }); } if (!fs.existsSync(absPath)) { @@ -1913,9 +1931,8 @@ router.put('/:companyId/workspace/files/content', (req, res) => { const filePath = req.body.path; if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); - const absPath = path.resolve(DATA_DIR, req.params.companyId, 'workspace', filePath); - const safeBoundary = path.resolve(DATA_DIR, req.params.companyId, 'workspace'); - if (!absPath.startsWith(safeBoundary)) { + const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId, 'workspace'), filePath); + if (!absPath) { return res.status(403).json({ error: '路径越界' }); } diff --git a/server/services/graph-seed-service.js b/server/services/graph-seed-service.js index 35f4c1d..fccc46c 100644 --- a/server/services/graph-seed-service.js +++ b/server/services/graph-seed-service.js @@ -530,7 +530,15 @@ function verifyCounts() { } const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : []; - const distinctRoles = new Set(roleNames); // org_graph_nodes 按 (company_id, system_agent_id, role_name) 去重 + // 必须镜像 seedCompanyAgents 的过滤:那边对 meta-bootstrap 角色是 `continue`(见 + // isMetaBootstrap 的调用点),既不落 org_graph_nodes 也不落 system_agents。 + // 这里以前把它算进期望值,于是**任何一家公司的 agents[] 里出现一个 meta-bootstrap + // 角色,对账就确定性变红**:实际发生过 —— mycompany 的 10 个角色里 orggraph-planning + // 是 meta-bootstrap,报 expected 10 / actual 9。 + // 复用同一个函数而不是复制那个 type 判断:两份判据迟早会漂,而漂了之后 + // 症状是「对账报错但库其实没问题」,比不对账更费人。 + const distinctRoles = new Set(roleNames.filter((r) => !isMetaBootstrap(companyId, r))); + // org_graph_nodes 按 (company_id, system_agent_id, role_name) 去重 expectedNodesPerCompany[companyId] = distinctRoles.size; for (const roleName of distinctRoles) { diff --git a/server/tests/unit/company-files-path-guard.test.js b/server/tests/unit/company-files-path-guard.test.js new file mode 100644 index 0000000..e859c16 --- /dev/null +++ b/server/tests/unit/company-files-path-guard.test.js @@ -0,0 +1,131 @@ +/** + * Unit Tests — 公司文件读写端点的路径越界防护 + * + * 修的是 `server/routes/company.js` 里六处(3 个 GET + 3 个 PUT,覆盖 + * templates / company / workspace)**同一个**写法: + * + * const absPath = path.resolve(DATA_DIR, cid, filePath); + * const safeBoundary = path.resolve(DATA_DIR, cid); + * if (!absPath.startsWith(safeBoundary)) → 403 + * + * `startsWith` 在「一个目录名是另一个的前缀」时会放行: + * cid = 'co-a',path = '../co-a-evil/company.json' + * → abs = .../data/companies/co-a-evil/company.json + * → abs.startsWith('.../data/companies/co-a') === true → 放行 + * 于是公司 co-a 能读 co-a-evil 的文件;PUT 那三处是同一个缝隙上的跨公司**写**。 + * + * 改成 path.relative 判断(越界时结果以 `..` 开头)。这组用例锁四件事: + * ① 正常相对路径读得到 + * ② `../<前缀兄弟目录>` 被拦(这是原 bug 的精确形态) + * ③ 绝对路径入参被拦 + * ④ PUT 也拦(写比读更要紧,且原代码两处是复制粘贴的同一个缝) + * + * 走真 HTTP:把 router 挂到真 express 上 listen(0) 再 fetch(同 devloop/routes.test.js)。 + * 不打桩 fs、不导出内部函数 —— 越界防护的正确性只在「真的读到了/没读到文件」这一侧成立。 + * + * ⚠️ 这个端点的根目录是**硬编码**的 `/data/companies`(无 env 覆盖口), + * 所以用例只能在真实目录下建两个 `__pathguard-*` 前缀的临时公司目录, + * afterAll 精确删掉自己建的那两个(不做通配清理,免得误删真公司)。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import express from 'express'; + +const ROOT = path.resolve(__dirname, '../../..'); +const COMPANIES_DIR = path.join(ROOT, 'data/companies'); + +// 前缀关系是本用例的核心:VICTIM 是 ATTACKER 的字符串前缀 +const VICTIM = '__pathguard-co'; +const SIBLING = '__pathguard-co-evil'; + +describe('公司文件端点:路径越界防护', () => { + let base; + let srv; + + beforeAll(async () => { + for (const c of [VICTIM, SIBLING]) { + fs.mkdirSync(path.join(COMPANIES_DIR, c), { recursive: true }); + } + fs.writeFileSync(path.join(COMPANIES_DIR, VICTIM, 'own.md'), '# 我自己的文件\n'); + fs.writeFileSync(path.join(COMPANIES_DIR, SIBLING, 'secret.md'), 'SIBLING-SECRET\n'); + + const app = express(); + app.use(express.json()); + app.use('/api/company', require('../../routes/company')); + await new Promise((resolve) => { + srv = app.listen(0, '127.0.0.1', () => { + base = `http://127.0.0.1:${srv.address().port}`; + resolve(); + }); + }); + }); + + afterAll(async () => { + if (srv) await new Promise((r) => srv.close(r)); + // 只删自己建的那两个目录,逐个具名 —— 不用通配 + for (const c of [VICTIM, SIBLING]) { + fs.rmSync(path.join(COMPANIES_DIR, c), { recursive: true, force: true }); + } + }); + + const get = (cid, p) => + fetch(`${base}/api/company/${cid}/files/content?path=${encodeURIComponent(p)}`); + + it('① 正常相对路径:读得到自己的文件', async () => { + const r = await get(VICTIM, 'own.md'); + expect(r.status).toBe(200); + const d = await r.json(); + expect(d.content).toContain('我自己的文件'); + }); + + it('② 前缀兄弟目录(原 bug 的精确形态)→ 403,且内容一个字都不回', async () => { + const r = await get(VICTIM, `../${SIBLING}/secret.md`); + expect(r.status).toBe(403); + const body = await r.text(); + // 不只看状态码:确认没有把内容顺带回出去 + expect(body).not.toContain('SIBLING-SECRET'); + }); + + it('② 更远的越界(爬到仓库根读源码)→ 403', async () => { + const r = await get(VICTIM, '../../../package.json'); + expect(r.status).toBe(403); + }); + + it('③ 绝对路径入参 → 403', async () => { + const r = await get(VICTIM, '/etc/hosts'); + expect(r.status).toBe(403); + }); + + it('④ PUT 同样拦住跨公司写(原代码那三处是同一个缝的复制粘贴)', async () => { + const target = path.join(COMPANIES_DIR, SIBLING, 'secret.md'); + const before = fs.readFileSync(target, 'utf-8'); + + const r = await fetch(`${base}/api/company/${VICTIM}/files/content`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: `../${SIBLING}/secret.md`, content: 'PWNED' }), + }); + + expect(r.status).toBe(403); + // 真正要断言的是**文件没被改**,而不是只看响应码 + expect(fs.readFileSync(target, 'utf-8')).toBe(before); + }); + + it('④ PUT 正常路径仍然写得进去(别把功能一起拦死)', async () => { + const r = await fetch(`${base}/api/company/${VICTIM}/files/content`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: 'nested/new.md', content: 'hello' }), + }); + expect(r.status).toBe(200); + expect(fs.readFileSync(path.join(COMPANIES_DIR, VICTIM, 'nested/new.md'), 'utf-8')).toBe('hello'); + }); + + it('workspace 端点走同一个 helper,同样拦住', async () => { + const r = await fetch( + `${base}/api/company/${VICTIM}/workspace/files/content?path=${encodeURIComponent(`../../${SIBLING}/secret.md`)}` + ); + expect(r.status).toBe(403); + }); +}); diff --git a/server/tests/unit/graph-seed-meta-bootstrap.test.js b/server/tests/unit/graph-seed-meta-bootstrap.test.js new file mode 100644 index 0000000..f77b6fe --- /dev/null +++ b/server/tests/unit/graph-seed-meta-bootstrap.test.js @@ -0,0 +1,97 @@ +/** + * Unit Tests — verifyCounts 必须镜像 seedCompanyAgents 的 meta-bootstrap 过滤 + * + * `seedCompanyAgents` 对 `type: 'meta-bootstrap'` 的公司角色是 `continue`: + * 既不写 org_graph_nodes 也不写 system_agents。而 `verifyCounts` 复算期望值时 + * 原先把 `company.json` 的 `agents[]` **全部**算进去 —— 于是任何一家公司的 + * agents[] 里出现一个 meta-bootstrap 角色,对账就确定性变红。 + * + * 不是假设:用户手工走查建的 `mycompany` 有 10 个角色,其中 `orggraph-planning` + * 是 meta-bootstrap,对账报 `expected 10 / actual 9`。也就是说这条缺陷是被 + * **正常使用**触发的 —— 界面上点一遍新建公司就会踩到。 + * + * 这个文件单独存在(不并进 graph-seed-service.test.js)的原因:那边 hermetic 块的 + * 断言钉着精确的行数/节点数,往它的夹具里加一个角色会连带改掉既有断言。 + * 这里用 `GRAPH_SEED_ROOT` 自建一个只有两个角色的最小夹具,互不干扰。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const MODULES = ['../../services/graph-db', '../../services/graph-seed-service']; + +describe('verifyCounts × meta-bootstrap 过滤', () => { + let seed; + let root; + let dbPath; + + const COMPANY = 'co-mb'; + const NORMAL_ROLE = 'org-planner'; + const BOOTSTRAP_ROLE = 'orggraph-planning'; + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'seed-mb-root-')); + dbPath = path.join(root, 'graph.sqlite'); + + // 最小夹具:system/agents//agent.md + system/templates/_registry.json + // + data/companies//{company.json, agents//config.json} + for (const id of [NORMAL_ROLE, BOOTSTRAP_ROLE]) { + const d = path.join(root, 'system', 'agents', id); + fs.mkdirSync(d, { recursive: true }); + fs.writeFileSync(path.join(d, 'agent.md'), `# ${id}\n`); + } + fs.mkdirSync(path.join(root, 'system', 'templates'), { recursive: true }); + fs.writeFileSync(path.join(root, 'system', 'templates', '_registry.json'), JSON.stringify({})); + + const companyDir = path.join(root, 'data', 'companies', COMPANY); + fs.mkdirSync(companyDir, { recursive: true }); + fs.writeFileSync( + path.join(companyDir, 'company.json'), + JSON.stringify({ id: COMPANY, name: COMPANY, agents: [NORMAL_ROLE, BOOTSTRAP_ROLE] }) + ); + // 一个普通角色 + 一个 meta-bootstrap 角色(后者是 seed 会跳过的那类) + for (const [role, type] of [[NORMAL_ROLE, 'executor'], [BOOTSTRAP_ROLE, 'meta-bootstrap']]) { + const rd = path.join(companyDir, 'agents', role); + fs.mkdirSync(rd, { recursive: true }); + fs.writeFileSync(path.join(rd, 'config.json'), JSON.stringify({ type })); + } + + process.env.GRAPH_SEED_ROOT = root; + process.env.GRAPH_DB_PATH = dbPath; + for (const m of MODULES) delete require.cache[require.resolve(m)]; + seed = require('../../services/graph-seed-service'); + }); + + afterAll(() => { + delete process.env.GRAPH_SEED_ROOT; + delete process.env.GRAPH_DB_PATH; + try { fs.rmSync(root, { recursive: true, force: true }); } catch {} + for (const m of MODULES) delete require.cache[require.resolve(m)]; + }); + + it('种完之后对账干净:meta-bootstrap 角色不算进期望值', () => { + // runStartupSeed 只做 reconcile + 全局 agent + 模板,**不含** seedCompanyAgents + // (两个 server 入口启动时也不种公司角色)—— 所以公司那部分要显式调。 + // 少了这一行的话对账会报 expected 1 / actual 0,看着像本条要防的那个 bug, + // 其实是夹具没种东西:两种原因症状相同,所以下面第二条用例专门把它们分开。 + seed.runStartupSeed(); + seed.seedCompanyAgents(); + const v = seed.verifyCounts(); + // 期望值把 meta-bootstrap 算进去时,这里会是 + // [{check: "org_graph_nodes COUNT(*) WHERE company_id='co-mb'", expected: 2, actual: 1}] + expect(v.mismatches).toEqual([]); + expect(v.ok).toBe(true); + }); + + it('这个夹具确实覆盖到了那条分支(否则上一条是空跑)', () => { + const db = require('../../services/graph-db').getDb(); + const rows = db + .prepare(`SELECT role_name FROM org_graph_nodes WHERE company_id = ? ORDER BY role_name`) + .all(COMPANY) + .map((r) => r.role_name); + // 普通角色种进去了、meta-bootstrap 角色没有 —— 两侧都断言, + // 只断言「对账干净」的话,seed 一个都没种时它也会“通过” + expect(rows).toEqual([NORMAL_ROLE]); + }); +}); diff --git a/web/src/components/graph-console/FilePreviewModal.vue b/web/src/components/graph-console/FilePreviewModal.vue new file mode 100644 index 0000000..acede3b --- /dev/null +++ b/web/src/components/graph-console/FilePreviewModal.vue @@ -0,0 +1,225 @@ + + + + + + diff --git a/web/src/components/graph-console/NodeDetailPanel.vue b/web/src/components/graph-console/NodeDetailPanel.vue index 368fd2f..be54155 100644 --- a/web/src/components/graph-console/NodeDetailPanel.vue +++ b/web/src/components/graph-console/NodeDetailPanel.vue @@ -159,7 +159,12 @@ 不受拓扑相邻性限制(R-9.3) -

{{ i.path }}

+

⚠️ 图上声明了这份输入,索引里查不到实例 —— 先确认是不是上游没产出

@@ -189,7 +194,11 @@ 已被新版本取代 {{ o.createdAt || '' }} -

{{ o.path }}

+

@@ -289,13 +298,23 @@ + +