From 5e2ba5b006f35cdb59e82d47b203eed7b11abb6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Mon, 3 Aug 2026 22:27:09 +0800 Subject: [PATCH] feat(authz): add atomic mobile knowledge writes --- server-tools/lake-lamp-authz/README.md | 4 + .../lake-lamp-authz/hololake-api.test.js | 82 ++++++ .../lake-lamp-authz/hololake-capabilities.js | 248 ++++++++++++++++-- .../hololake-capabilities.test.js | 150 +++++++++++ .../lake-lamp-authz/lake-lamp-authz.service | 4 +- server-tools/lake-lamp-authz/server.js | 58 +++- 6 files changed, 527 insertions(+), 19 deletions(-) diff --git a/server-tools/lake-lamp-authz/README.md b/server-tools/lake-lamp-authz/README.md index e749f6b..8c08e32 100644 --- a/server-tools/lake-lamp-authz/README.md +++ b/server-tools/lake-lamp-authz/README.md @@ -35,6 +35,10 @@ HoloLake 手机端使用独立的邮箱验证码会话,不复用工单批准 - 六位验证码只在邮件正文出现,服务器状态仅保存带私密 pepper 的摘要,十分钟失效且最多尝试五次; - 会话绑定 HoloLake 设备编号,手机仅保存短期会话令牌;服务器磁盘仍只保存令牌摘要; - `/api/hololake/knowledge/manifest` 与 `/archive` 只暴露固定登记仓库 +- `PUT /api/hololake/knowledge/page` 只接受普通 Markdown、精确 `main` + 基线与已绑定设备会话;服务器用临时 Git index 原子前移裸仓库并返回提交回执 +- `本地密钥/**`、路径穿越、非 Markdown、超限内容与过期基线全部失败关闭,回执 + 不包含页面内容 `bingshuo/hololake-knowledge-base` 的当前 `refs/heads/main` 快照,不向手机下发 Forgejo 凭据; - `/api/hololake/ai/catalog` 只返回可用模型编号;`/execute` 只调用服务器登记的提供商与模型, 不接受任意 URL、请求头或密钥,响应和回执均不包含服务器密钥。 diff --git a/server-tools/lake-lamp-authz/hololake-api.test.js b/server-tools/lake-lamp-authz/hololake-api.test.js index c64ddf7..2c68efa 100644 --- a/server-tools/lake-lamp-authz/hololake-api.test.js +++ b/server-tools/lake-lamp-authz/hololake-api.test.js @@ -36,6 +36,25 @@ async function withServer(run) { content_type: "application/zip", body: Buffer.from("PK-test-archive"), }), + writePage: request => { + if (request.path.includes("本地密钥")) { + throw new Error("knowledge_secret_page_forbidden"); + } + if (request.path === "notes/conflict.md") { + throw new Error("knowledge_commit_conflict"); + } + return { + schema: "guanghu.hololake-knowledge-write-receipt/v1", + repository: "bingshuo/hololake-knowledge-base", + path: request.path, + base_commit: request.baseCommit, + commit: "c".repeat(40), + content_sha256: crypto + .createHash("sha256") + .update(request.content) + .digest("hex"), + }; + }, }; const aiGateway = { catalog: () => ({ @@ -177,6 +196,21 @@ test("authenticated device can read fixed knowledge, use AI proxy, inspect and r assert.equal(archive.headers.get("x-content-sha256"), "b".repeat(64)); assert.equal(Buffer.from(await archive.arrayBuffer()).toString(), "PK-test-archive"); + const pageWrite = await fetch(`${base}/api/hololake/knowledge/page`, { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + path: "个人知识/手机页面.md", + content: "# 手机页面\n", + base_commit: "a".repeat(40), + }), + }); + assert.equal(pageWrite.status, 200); + const writeReceipt = await pageWrite.json(); + assert.equal(writeReceipt.path, "个人知识/手机页面.md"); + assert.equal(writeReceipt.commit, "c".repeat(40)); + assert.doesNotMatch(JSON.stringify(writeReceipt), /# 手机页面/); + const ai = await fetch(`${base}/api/hololake/ai/execute`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, @@ -221,6 +255,18 @@ test("knowledge and AI endpoints require the session and matching device", async })).status, 401, ); + assert.equal( + (await fetch(`${base}/api/hololake/knowledge/page`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + path: "notes/no-session.md", + content: "# no\n", + base_commit: "a".repeat(40), + }), + })).status, + 401, + ); const token = await login(base, mail); assert.equal( @@ -234,3 +280,39 @@ test("knowledge and AI endpoints require the session and matching device", async ); }); }); + +test("knowledge write endpoint maps conflicts and validation failures without echoing content", async () => { + await withServer(async ({ base, mail }) => { + const deviceId = "ios-device-001"; + const token = await login(base, mail, deviceId); + const headers = { + authorization: `Bearer ${token}`, + "x-hololake-device-id": deviceId, + "content-type": "application/json", + }; + + const forbidden = await fetch(`${base}/api/hololake/knowledge/page`, { + method: "PUT", + headers, + body: JSON.stringify({ + path: "本地密钥/openai.md", + content: "super-secret-value", + base_commit: "a".repeat(40), + }), + }); + assert.equal(forbidden.status, 400); + assert.doesNotMatch(await forbidden.text(), /super-secret-value/); + + const conflict = await fetch(`${base}/api/hololake/knowledge/page`, { + method: "PUT", + headers, + body: JSON.stringify({ + path: "notes/conflict.md", + content: "# stale\n", + base_commit: "a".repeat(40), + }), + }); + assert.equal(conflict.status, 409); + assert.equal((await conflict.json()).error, "knowledge_commit_conflict"); + }); +}); diff --git a/server-tools/lake-lamp-authz/hololake-capabilities.js b/server-tools/lake-lamp-authz/hololake-capabilities.js index fcea77d..b6d709f 100644 --- a/server-tools/lake-lamp-authz/hololake-capabilities.js +++ b/server-tools/lake-lamp-authz/hololake-capabilities.js @@ -3,6 +3,10 @@ const childProcess = require("node:child_process"); const crypto = require("node:crypto"); const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const MAX_KNOWLEDGE_PAGE_BYTES = 1024 * 1024; class HoloLakeKnowledgeProvider { constructor(options = {}) { @@ -63,14 +67,104 @@ class HoloLakeKnowledgeProvider { }; } - git(args, maxBuffer = 1024 * 1024) { + writePage(request = {}) { + const pagePath = validateKnowledgePagePath(request.path); + if (typeof request.content !== "string") { + throw new Error("knowledge_page_content_invalid"); + } + const content = Buffer.from(request.content, "utf8"); + if (content.length > MAX_KNOWLEDGE_PAGE_BYTES) { + throw new Error("knowledge_page_too_large"); + } + if (content.includes(0)) { + throw new Error("knowledge_page_content_invalid"); + } + const current = this.manifest().commit; + const baseCommit = String(request.baseCommit || ""); + if (!/^[0-9a-f]{40}$/.test(baseCommit) || !safeEqual(baseCommit, current)) { + throw new Error("knowledge_commit_conflict"); + } + + const temporary = fs.mkdtempSync( + path.join(os.tmpdir(), "hololake-knowledge-index-"), + ); + const indexFile = path.join(temporary, "index"); + const gitEnv = { + ...process.env, + GIT_INDEX_FILE: indexFile, + GIT_AUTHOR_NAME: "HoloLake Knowledge", + GIT_AUTHOR_EMAIL: "knowledge@guanghulake.invalid", + GIT_COMMITTER_NAME: "HoloLake Knowledge", + GIT_COMMITTER_EMAIL: "knowledge@guanghulake.invalid", + }; + try { + this.git(["read-tree", current], 1024 * 1024, { env: gitEnv }); + const blob = this.git( + ["hash-object", "-w", "--stdin"], + MAX_KNOWLEDGE_PAGE_BYTES + 1024, + { env: gitEnv, input: content }, + ).toString("utf8").trim(); + if (!/^[0-9a-f]{40}$/.test(blob)) { + throw new Error("knowledge_blob_invalid"); + } + this.git( + ["update-index", "--add", "--cacheinfo", "100644", blob, pagePath], + 1024 * 1024, + { env: gitEnv }, + ); + const tree = this.git(["write-tree"], 1024 * 1024, { env: gitEnv }) + .toString("utf8") + .trim(); + if (!/^[0-9a-f]{40}$/.test(tree)) { + throw new Error("knowledge_tree_invalid"); + } + const nextCommit = this.git( + [ + "commit-tree", + tree, + "-p", + current, + "-m", + `HoloLake knowledge: ${pagePath}`, + ], + 1024 * 1024, + { env: gitEnv }, + ).toString("utf8").trim(); + if (!/^[0-9a-f]{40}$/.test(nextCommit)) { + throw new Error("knowledge_commit_invalid"); + } + try { + this.git( + ["update-ref", this.ref, nextCommit, current], + 1024 * 1024, + { env: gitEnv }, + ); + } catch { + throw new Error("knowledge_commit_conflict"); + } + return { + schema: "guanghu.hololake-knowledge-write-receipt/v1", + repository: this.repositoryId, + path: pagePath, + base_commit: current, + commit: nextCommit, + content_sha256: crypto.createHash("sha256").update(content).digest("hex"), + }; + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } + } + + git(args, maxBuffer = 1024 * 1024, options = {}) { return childProcess.execFileSync( "git", [`--git-dir=${this.repositoryPath}`, ...args], { encoding: "buffer", maxBuffer, - stdio: ["ignore", "pipe", "pipe"], + stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], + env: options.env || process.env, + input: options.input, }, ); } @@ -119,6 +213,7 @@ class HoloLakeAiGateway { this.maxMessages, this.maxInputCharacters, ); + const tools = validateTools(request.tools); const maxTokens = Number(request.max_tokens || 1024); if ( !Number.isInteger(maxTokens) @@ -128,18 +223,23 @@ class HoloLakeAiGateway { throw new Error("ai_output_limit_exceeded"); } + const upstreamBody = { + model, + messages, + max_tokens: maxTokens, + stream: false, + }; + if (tools.length > 0) { + upstreamBody.tools = tools; + upstreamBody.tool_choice = request.tool_choice === "none" ? "none" : "auto"; + } const response = await this.fetchImpl(`${provider.baseUrl}/chat/completions`, { method: "POST", headers: { authorization: `Bearer ${provider.apiKey}`, "content-type": "application/json", }, - body: JSON.stringify({ - model, - messages, - max_tokens: maxTokens, - stream: false, - }), + body: JSON.stringify(upstreamBody), signal: AbortSignal.timeout(provider.timeoutMs), }); const text = await response.text(); @@ -215,26 +315,144 @@ function validateMessages(messages, maxMessages, maxInputCharacters) { if (messages.length > maxMessages) throw new Error("ai_input_too_large"); let characters = 0; const normalized = messages.map(message => { - if ( - !message - || !["system", "user", "assistant"].includes(message.role) - || typeof message.content !== "string" - ) { + if (!message || !["system", "user", "assistant", "tool"].includes(message.role)) { throw new Error("ai_message_invalid"); } - characters += message.content.length; - return { role: message.role, content: message.content }; + if (message.role === "tool") { + if ( + typeof message.content !== "string" + || !/^[A-Za-z0-9._:-]{1,160}$/.test(String(message.tool_call_id || "")) + ) { + throw new Error("ai_message_invalid"); + } + characters += message.content.length; + return { + role: "tool", + tool_call_id: message.tool_call_id, + content: message.content, + }; + } + const content = message.content === null && message.role === "assistant" + ? null + : message.content; + if (typeof content !== "string" && content !== null) { + throw new Error("ai_message_invalid"); + } + characters += typeof content === "string" ? content.length : 0; + const normalizedMessage = { role: message.role, content }; + if (message.role === "assistant" && message.tool_calls !== undefined) { + normalizedMessage.tool_calls = validateToolCalls(message.tool_calls); + characters += normalizedMessage.tool_calls.reduce( + (total, call) => total + call.function.arguments.length, + 0, + ); + } + return normalizedMessage; }); if (characters > maxInputCharacters) throw new Error("ai_input_too_large"); return normalized; } +function validateToolCalls(value) { + if (!Array.isArray(value) || value.length < 1 || value.length > 32) { + throw new Error("ai_message_invalid"); + } + return value.map(call => { + if ( + !call + || call.type !== "function" + || !/^[A-Za-z0-9._:-]{1,160}$/.test(String(call.id || "")) + || !call.function + || !/^[A-Za-z0-9_-]{1,80}$/.test(String(call.function.name || "")) + || typeof call.function.arguments !== "string" + || call.function.arguments.length > 32 * 1024 + ) { + throw new Error("ai_message_invalid"); + } + return { + id: call.id, + type: "function", + function: { + name: call.function.name, + arguments: call.function.arguments, + }, + }; + }); +} + +function validateTools(value) { + if (value === undefined || value === null) return []; + if (!Array.isArray(value) || value.length > 32) { + throw new Error("ai_tools_invalid"); + } + const encoded = JSON.stringify(value); + if (Buffer.byteLength(encoded) > 128 * 1024) { + throw new Error("ai_tools_invalid"); + } + return value.map(tool => { + if ( + !tool + || tool.type !== "function" + || !tool.function + || !/^[A-Za-z0-9_-]{1,80}$/.test(String(tool.function.name || "")) + || typeof tool.function.description !== "string" + || tool.function.description.length > 4_000 + || !tool.function.parameters + || typeof tool.function.parameters !== "object" + || Array.isArray(tool.function.parameters) + ) { + throw new Error("ai_tools_invalid"); + } + return { + type: "function", + function: { + name: tool.function.name, + description: tool.function.description, + parameters: JSON.parse(JSON.stringify(tool.function.parameters)), + }, + }; + }); +} + function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); } +function validateKnowledgePagePath(value) { + const pagePath = String(value || "").normalize("NFC"); + if ( + pagePath.length < 1 + || pagePath.length > 512 + || pagePath.startsWith("/") + || pagePath.endsWith("/") + || pagePath.includes("\\") + || /[\0-\x1f\x7f]/u.test(pagePath) + || !pagePath.endsWith(".md") + ) { + throw new Error("knowledge_page_path_invalid"); + } + const components = pagePath.split("/"); + if ( + components.length < 1 + || components.length > 16 + || components.some(component => ( + !component + || component === "." + || component === ".." + || component.length > 120 + || component.toLocaleLowerCase("en-US") === ".git" + )) + ) { + throw new Error("knowledge_page_path_invalid"); + } + if (components.some(component => component.normalize("NFKC") === "本地密钥")) { + throw new Error("knowledge_secret_page_forbidden"); + } + return pagePath; +} + module.exports = { HoloLakeAiGateway, HoloLakeKnowledgeProvider, diff --git a/server-tools/lake-lamp-authz/hololake-capabilities.test.js b/server-tools/lake-lamp-authz/hololake-capabilities.test.js index bad0ab8..3ddcf21 100644 --- a/server-tools/lake-lamp-authz/hololake-capabilities.test.js +++ b/server-tools/lake-lamp-authz/hololake-capabilities.test.js @@ -54,6 +54,93 @@ test("knowledge provider exposes only the fixed main commit and archive", () => ); }); +test("knowledge provider atomically writes Markdown and returns a verifiable commit receipt", () => { + const repository = createBareKnowledgeRepository(); + const provider = new HoloLakeKnowledgeProvider({ + repositoryId: "bingshuo/hololake-knowledge-base", + repositoryPath: repository.bare, + }); + const base = provider.manifest().commit; + const content = "# 手机知识页\n\n由 HoloLake Agent 写入。\n"; + + const receipt = provider.writePage({ + path: "个人知识/手机知识页.md", + content, + baseCommit: base, + }); + + assert.equal(receipt.schema, "guanghu.hololake-knowledge-write-receipt/v1"); + assert.equal(receipt.repository, "bingshuo/hololake-knowledge-base"); + assert.equal(receipt.path, "个人知识/手机知识页.md"); + assert.equal(receipt.base_commit, base); + assert.match(receipt.commit, /^[0-9a-f]{40}$/); + assert.notEqual(receipt.commit, base); + assert.equal( + receipt.content_sha256, + require("node:crypto").createHash("sha256").update(content).digest("hex"), + ); + assert.equal(provider.manifest().commit, receipt.commit); + assert.equal( + childProcess.execFileSync( + "git", + [`--git-dir=${repository.bare}`, "show", `${receipt.commit}:个人知识/手机知识页.md`], + { encoding: "utf8" }, + ), + content, + ); + assert.doesNotMatch(JSON.stringify(receipt), /由 HoloLake Agent 写入/); +}); + +test("knowledge provider rejects secret pages, unsafe paths, oversized content, and stale commits", () => { + const repository = createBareKnowledgeRepository(); + const provider = new HoloLakeKnowledgeProvider({ + repositoryId: "bingshuo/hololake-knowledge-base", + repositoryPath: repository.bare, + }); + const base = provider.manifest().commit; + + for (const invalidPath of [ + "本地密钥/openai.md", + "../outside.md", + "notes/plain.txt", + "/absolute.md", + "notes//empty.md", + ]) { + assert.throws( + () => provider.writePage({ + path: invalidPath, + content: "# blocked\n", + baseCommit: base, + }), + /knowledge_page_path_invalid|knowledge_secret_page_forbidden/, + ); + } + assert.throws( + () => provider.writePage({ + path: "notes/large.md", + content: "x".repeat(1024 * 1024 + 1), + baseCommit: base, + }), + /knowledge_page_too_large/, + ); + + const first = provider.writePage({ + path: "notes/first.md", + content: "# first\n", + baseCommit: base, + }); + assert.match(first.commit, /^[0-9a-f]{40}$/); + assert.throws( + () => provider.writePage({ + path: "notes/stale.md", + content: "# stale\n", + baseCommit: base, + }), + /knowledge_commit_conflict/, + ); + assert.equal(provider.manifest().commit, first.commit); +}); + test("AI gateway accepts only registered provider models and never leaks the key", async () => { const requests = []; const gateway = new HoloLakeAiGateway({ @@ -115,6 +202,69 @@ test("AI gateway accepts only registered provider models and never leaks the key assert.equal(requests.length, 1); }); +test("AI gateway forwards bounded tool contracts and tool receipts without exposing server secrets", async () => { + const requests = []; + const gateway = new HoloLakeAiGateway({ + providers: { + default: { + baseUrl: "https://models.example.invalid/v1", + apiKey: "server-secret-key", + models: ["gpt-test"], + }, + }, + fetchImpl: async (_url, options) => { + requests.push(JSON.parse(options.body)); + return new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", content: "saved" } }], + }), { status: 200 }); + }, + }); + const result = await gateway.execute({ + provider: "default", + model: "gpt-test", + messages: [ + { role: "user", content: "save this" }, + { + role: "assistant", + content: null, + tool_calls: [{ + id: "call-1", + type: "function", + function: { name: "create_note", arguments: "{\"path\":\"notes/a.md\"}" }, + }], + }, + { role: "tool", tool_call_id: "call-1", content: "commit=abc" }, + ], + tools: [{ + type: "function", + function: { + name: "create_note", + description: "Create one Markdown page.", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }], + tool_choice: "auto", + }); + assert.equal(result.response.choices[0].message.content, "saved"); + assert.equal(requests[0].tools[0].function.name, "create_note"); + assert.equal(requests[0].messages[2].role, "tool"); + assert.equal(requests[0].tool_choice, "auto"); + assert.doesNotMatch(JSON.stringify(result), /server-secret-key/); + + await assert.rejects( + () => gateway.execute({ + provider: "default", + model: "gpt-test", + messages: [{ role: "tool", tool_call_id: "../bad", content: "x" }], + }), + /ai_message_invalid/, + ); +}); + test("AI gateway bounds message shape, count, text, and requested output", async () => { const gateway = new HoloLakeAiGateway({ providers: { diff --git a/server-tools/lake-lamp-authz/lake-lamp-authz.service b/server-tools/lake-lamp-authz/lake-lamp-authz.service index b14271f..5c97b0e 100644 --- a/server-tools/lake-lamp-authz/lake-lamp-authz.service +++ b/server-tools/lake-lamp-authz/lake-lamp-authz.service @@ -16,8 +16,8 @@ NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations /var/lib/guanghu/repo-push-uploads /var/lib/guanghu/forgejo/repositories/bingshuo/hololake-platform.git /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/guanghu-ice-heart.git -ReadOnlyPaths=-/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git -/etc/guanghu/secrets/hololake-ai-providers.json +ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations /var/lib/guanghu/repo-push-uploads /var/lib/guanghu/forgejo/repositories/bingshuo/hololake-platform.git /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/guanghu-ice-heart.git -/var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories/bingshuo/hololake-knowledge-base.git +ReadOnlyPaths=-/etc/guanghu/secrets/hololake-ai-providers.json RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 LockPersonality=true diff --git a/server-tools/lake-lamp-authz/server.js b/server-tools/lake-lamp-authz/server.js index 3af5ea0..508db42 100644 --- a/server-tools/lake-lamp-authz/server.js +++ b/server-tools/lake-lamp-authz/server.js @@ -437,6 +437,49 @@ function createApp(options = {}) { } } + if ( + req.method === "PUT" + && url.pathname === "/api/hololake/knowledge/page" + ) { + const authenticated = authenticateHoloLake(req); + if (!authenticated.ok) { + return json( + res, + authenticated.status, + failure(authenticated.error), + ); + } + if (!hololakeKnowledgeProvider) { + return json(res, 503, failure("knowledge_repository_unavailable")); + } + const body = await readJson(req, 1024 * 1024 + 16 * 1024); + if (!body || typeof body !== "object") { + return json(res, 400, failure("knowledge_page_request_invalid")); + } + try { + const receipt = hololakeKnowledgeProvider.writePage({ + path: body.path, + content: body.content, + baseCommit: body.base_commit, + }); + return json(res, 200, receipt); + } catch (error) { + const code = String(error && error.message || ""); + if (code === "knowledge_commit_conflict") { + return json(res, 409, failure(code)); + } + if ([ + "knowledge_page_path_invalid", + "knowledge_secret_page_forbidden", + "knowledge_page_content_invalid", + "knowledge_page_too_large", + ].includes(code)) { + return json(res, 400, failure(code)); + } + return json(res, 503, failure("knowledge_repository_unavailable")); + } + } + if ( req.method === "GET" && url.pathname === "/api/hololake/knowledge/archive" @@ -1252,10 +1295,21 @@ function document(title, body) {
${body}
`; } -function readJson(req) { +function readJson(req, maxBytes = 32 * 1024) { return new Promise((resolve, reject) => { let raw = ""; - req.on("data", chunk => { raw += chunk; if (raw.length > 32 * 1024) { const error = new Error("body too large"); error.code = "BODY_TOO_LARGE"; reject(error); req.destroy(); } }); + let received = 0; + req.on("data", chunk => { + received += chunk.length; + if (received > maxBytes) { + const error = new Error("body too large"); + error.code = "BODY_TOO_LARGE"; + reject(error); + req.destroy(); + return; + } + raw += chunk; + }); req.on("end", () => { try { resolve(JSON.parse(raw || "{}")); } catch { resolve(null); } }); req.on("error", reject); });