10d150ac2b
leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
病根(不是金鑰沒同步,是身分沒接住):
oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。
修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
display_name/role/libraries 存進 authorization code → access token。
/portal/login 補回 session_expires_in,access_token TTL 夾成
min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。
新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
GET /portal/data/map、/portal/data/map/:library
GET /portal/data/templates、POST /portal/data/templates
GET /portal/data/records/by-template/:t、GET /portal/data/records/:id
POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。
KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。
沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。
驗證:
mcp tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
cypher vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
kbdb vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
端到端 ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
80 lines
3.6 KiB
TypeScript
80 lines
3.6 KiB
TypeScript
/**
|
||
* arcrun_whoami — MCP 端的「我現在是誰、連哪台」(§7.8 P1 D2,與 CLI acr whoami 對齊)。
|
||
*
|
||
* D2 根因(self-hosted-init.md §7.8):AI 不用工具讀帳號、自己 curl 猜全域 → 打錯帳號。
|
||
* 治本是給 AI 無腦入口:問工具拿身份。CLI 有 acr whoami,MCP 必須對齊(薄殼一致,rule 07 §5)——
|
||
* 否則「AI 偏好 MCP」時又得繞回 curl。
|
||
*
|
||
* 2026-08-12 改:以帳密連線時,「我是誰」的答案是**登入的那個人**(display_name / role /
|
||
* 可用知識庫),不是一個租戶代號。原本回的 account_namespace 是租戶字串——那東西一旦落到
|
||
* 呼叫端手上就能拿去直打 /kbdb/*、繞過所有庫過濾(portal-data.ts 檔頭紅線),所以登入身分下
|
||
* 不再回它。工作流面(arcrun_*)仍用它當 API key,但那只在 server 內部用。
|
||
*
|
||
* 薄殼:只如實回報 MCP 已解析的身分,不做推斷、不打任何查詢。
|
||
*/
|
||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import { toolName } from "../brand.js";
|
||
import { Env } from "../types.js";
|
||
import type { KnowledgeIdentity } from "../lib/portal-client.js";
|
||
|
||
export function registerWhoami(
|
||
server: McpServer,
|
||
env: Env,
|
||
orgNamespace: string,
|
||
identity: KnowledgeIdentity,
|
||
) {
|
||
server.tool(
|
||
toolName("whoami"),
|
||
"回報這個 MCP 連線目前生效的身份:以帳密連線時回「登入的是誰、能看哪些知識庫」;" +
|
||
"服務級 token 連線時回綁定的帳號 namespace。部署 / 觸發 / 查 workflow 前先 call 此 tool 確認身份," +
|
||
"**不要自己 curl 猜帳號 URL**(會打到錯帳號)。",
|
||
{},
|
||
async () => {
|
||
const base = {
|
||
cypher: "service-binding:CYPHER_EXECUTOR",
|
||
kbdb: "service-binding:KBDB",
|
||
};
|
||
|
||
if (identity.kind === "portal") {
|
||
const { display_name, role, libraries } = identity.portal;
|
||
return json({
|
||
...base,
|
||
auth: "portal-login(這條連線是有人輸入 Portal 帳密授權的)",
|
||
logged_in_as: display_name || "(未設顯示名稱)",
|
||
role,
|
||
libraries: libraries.length ? libraries : ["(尚未被授權任何知識庫)"],
|
||
knowledge_scope:
|
||
libraries.includes("*")
|
||
? "全部知識庫(此帳號有全庫權限)"
|
||
: `僅限上列知識庫——kbdb_* 查得到的東西與這個帳號在 portal 網頁上看得到的完全一致`,
|
||
note:
|
||
"你是「主人授權的 AI」:主人查得到的你查得到,主人查不到的你也查不到。" +
|
||
"kbdb_* 不需要任何額外的 credential / 金鑰 / kbdb_base——已經登入過了,不會再問第二次。",
|
||
});
|
||
}
|
||
|
||
if (identity.kind === "stale") {
|
||
return json({
|
||
...base,
|
||
auth: "舊版 token(沒有登入者身分)",
|
||
knowledge_scope: "查不到任何知識內容",
|
||
note:
|
||
"這條連線是本次改版前簽發的 token。到 claude.ai → Settings → Connectors " +
|
||
"重新連線一次(輸入 Portal 帳密)即可恢復,不需要找任何 credential。",
|
||
});
|
||
}
|
||
|
||
return json({
|
||
...base,
|
||
auth: "service token(static token / partner key,代表整個實例或租戶,不是某個人)",
|
||
account_namespace: orgNamespace || "(未設)",
|
||
note: "此 MCP 已綁定上述帳號。部署/觸發/查詢都走這個身份;勿自行 curl 其他 URL 猜帳號。",
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
function json(obj: unknown) {
|
||
return { content: [{ type: "text" as const, text: JSON.stringify(obj, null, 2) }] };
|
||
}
|