fix(security): sleeper 旗標不再是任意人格的萬用鑰匙(S1)
問題:`requireOwner()` 看到 sleeper 旗標就直接 return,把驗證整個外包給
PreToolUse hook;而 hook 認得出這支 CLI 靠的是檔名正則。把 scripts/ 複製出去、
CLI 改個名字,hook 全程回 pass,任何程序都能用那個旗標對**任意人格**
remember/recall/emotion --apply——等於完整讀寫權。
修法:CLI 自己驗,證據取自 hook 唯一寫得下、程序偽造不了的東西——
session 檔(`.runtime/sessions/<id>.json`)裡的 sleeper pin。pin 由 hook 依
`event.agent_type` 寫入,沒經過 hook 的程序拿不到。
- `pinAgent()` 多記角色(`{ persona, role, pinned_at }`),`pinOf()` 相容舊的純字串格式;
舊格式沒有角色,一律不算 sleeper 授權,hook 下次 first-touch 時會補上。
- 新增 `sleeperPins(sessionId, slug)`:本 session 中 pin 在該人格上的 sleeper。
- `requireSleeperPin()` 進 `sleeperAccess()`(requireOwner/requireMember 都會經過):
沒有對應的 sleeper pin 就 die,連 sleeper 租約都不會留下。
有帶 `--agent-id` 時額外要求它就是那個被 pin 的 agent。
- 連帶更新讀 pins 的三處:resolveScope、`leave` 的 guest pin 清理、SubagentStop hook。
persona-sleeper sub agent 的正常流程不受影響:hook 的 first-touch pinning 在它
第一個指令就寫好 pin,之後每個收尾指令都驗得過。
測試:新增 10 項(實際把 scripts/ 複製成別的檔名重現攻擊)——改名後 hook 確實
不表態、無 pin 時 remember/recall/emotion 都被 CLI 擋下且不留租約、pin 在別的
人格上不能跨過去、guest pin 與舊格式 pin 都不算授權、真有 pin 時照常放行。
345 → 355 項全過。
This commit is contained in:
@@ -453,6 +453,9 @@ node scripts/persona.mjs sleep --session <PERSONA_SESSION> --release # 收工
|
|||||||
- **一次睡多個人格**:可用 `sleep --personas A,B` 批次處理;主程序只是把流程排成一串,**每個人格仍各自判斷、各自收尾、各自回 JSON**。
|
- **一次睡多個人格**:可用 `sleep --personas A,B` 批次處理;主程序只是把流程排成一串,**每個人格仍各自判斷、各自收尾、各自回 JSON**。
|
||||||
- **回傳值刻意很窮**:`{persona, ok, slept_at, steps, sync, kept_lock}`——只有狀態。
|
- **回傳值刻意很窮**:`{persona, ok, slept_at, steps, sync, kept_lock}`——只有狀態。
|
||||||
回傳值本身就是一條會繞過隔離的通道,所以在 CLI 這一層封死,不靠提示詞自律。
|
回傳值本身就是一條會繞過隔離的通道,所以在 CLI 這一層封死,不靠提示詞自律。
|
||||||
|
- **`--as-sleeper` 由 CLI 自己驗**:它代表「我是 persona-sleeper 型 sub agent」,而型別只有 hook 看得到。
|
||||||
|
CLI 不把這件事外包給 hook(hook 認 CLI 靠檔名,改名就繞過去了),而是直接查 hook 寫在
|
||||||
|
`.runtime/sessions/<id>.json` 裡的 sleeper pin:本 session 沒有 pin 在目標人格上的 sleeper 就拒絕。
|
||||||
- **鎖**:沒有活鎖 → 取 5 分鐘的 sleeper 租約;同 session → 直接睡;死鎖 → 可接手;
|
- **鎖**:沒有活鎖 → 取 5 分鐘的 sleeper 租約;同 session → 直接睡;死鎖 → 可接手;
|
||||||
**別的程序活鎖住 → 拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。
|
**別的程序活鎖住 → 拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。
|
||||||
- 順序上的硬相依:關係時間戳早於裁短期、push 早於 release、reindex 晚於固化。
|
- 順序上的硬相依:關係時間戳早於裁短期、push 早於 release、reindex 晚於固化。
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ if (!agentId || (!isGuest && !isSleeper)) process.exit(0);
|
|||||||
|
|
||||||
const data = pl.loadSession(sessionId);
|
const data = pl.loadSession(sessionId);
|
||||||
const pins = data.pins || {};
|
const pins = data.pins || {};
|
||||||
const slug = pins[agentId];
|
const slug = pl.pinOf(data, agentId)?.persona;
|
||||||
if (slug !== undefined) {
|
if (slug !== undefined) {
|
||||||
delete pins[agentId];
|
delete pins[agentId];
|
||||||
data.pins = pins;
|
data.pins = pins;
|
||||||
|
|||||||
+53
-9
@@ -2838,7 +2838,8 @@ export function resolveScope(sessionId, agentId, agentType) {
|
|||||||
const isGuestAgent = Boolean(agentType) && type.includes("persona-guest");
|
const isGuestAgent = Boolean(agentType) && type.includes("persona-guest");
|
||||||
const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper");
|
const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper");
|
||||||
if (isSleeperAgent) {
|
if (isSleeperAgent) {
|
||||||
const pinned = (data.pins || {})[agentId || ""];
|
const pin = pinOf(data, agentId);
|
||||||
|
const pinned = pin?.persona || undefined;
|
||||||
return {
|
return {
|
||||||
role: "sleeper",
|
role: "sleeper",
|
||||||
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
|
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
|
||||||
@@ -2847,6 +2848,7 @@ export function resolveScope(sessionId, agentId, agentType) {
|
|||||||
host,
|
host,
|
||||||
guests,
|
guests,
|
||||||
pinned,
|
pinned,
|
||||||
|
pinnedRole: pin?.role ?? null,
|
||||||
rooms: [],
|
rooms: [],
|
||||||
session: data,
|
session: data,
|
||||||
};
|
};
|
||||||
@@ -2862,7 +2864,8 @@ export function resolveScope(sessionId, agentId, agentType) {
|
|||||||
session: data,
|
session: data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const pinned = (data.pins || {})[agentId || ""];
|
const pin = pinOf(data, agentId);
|
||||||
|
const pinned = pin?.persona || undefined;
|
||||||
return {
|
return {
|
||||||
role: "guest",
|
role: "guest",
|
||||||
allowed: pinned ? [pinned] : guests,
|
allowed: pinned ? [pinned] : guests,
|
||||||
@@ -2870,18 +2873,52 @@ export function resolveScope(sessionId, agentId, agentType) {
|
|||||||
host,
|
host,
|
||||||
guests,
|
guests,
|
||||||
pinned,
|
pinned,
|
||||||
|
pinnedRole: pin?.role ?? null,
|
||||||
rooms: data.rooms || [],
|
rooms: data.rooms || [],
|
||||||
session: data,
|
session: data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pinAgent(sessionId, agentId, slug) {
|
/**
|
||||||
|
* 讀出某個 sub agent 的 pin。
|
||||||
|
*
|
||||||
|
* 舊格式是純字串(只有人格、沒有角色),新格式是 `{ persona, role, pinned_at }`。
|
||||||
|
* 角色要記下來的理由:`--as-sleeper` 的授權只能靠 pin,而 pin 是不是 sleeper 開的,
|
||||||
|
* 只有寫 pin 的當下(hook 看得到 `agent_type`)知道。
|
||||||
|
*/
|
||||||
|
export function pinOf(data, agentId) {
|
||||||
|
const raw = (data?.pins || {})[agentId ?? ""];
|
||||||
|
if (!raw) return null;
|
||||||
|
if (typeof raw === "string") return { persona: raw, role: null };
|
||||||
|
return { persona: raw.persona || null, role: raw.role || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pinAgent(sessionId, agentId, slug, role = null) {
|
||||||
const data = loadSession(sessionId);
|
const data = loadSession(sessionId);
|
||||||
data.pins ??= {};
|
data.pins ??= {};
|
||||||
if (data.pins[agentId] !== slug) {
|
const cur = pinOf(data, agentId);
|
||||||
data.pins[agentId] = slug;
|
if (cur && cur.persona === slug && cur.role === role) return;
|
||||||
|
data.pins[agentId] = { persona: slug, role, pinned_at: nowIso() };
|
||||||
saveSession(sessionId, data);
|
saveSession(sessionId, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本 session 裡「真的存在、而且 pin 在 `slug` 上」的 persona-sleeper sub agent。
|
||||||
|
*
|
||||||
|
* 這是 `--as-sleeper` 唯一可信的授權依據:pin 只由 PreToolUse hook 依 `agent_type` 寫入,
|
||||||
|
* 而 `agent_type` 是任何被啟動的程序自己看不到、也偽造不了的東西。
|
||||||
|
* CLI 不能改用「hook 有沒有擋我」當證明——hook 認 CLI 靠檔名正則,改個檔名就繞過去了。
|
||||||
|
*/
|
||||||
|
export function sleeperPins(sessionId, slug = null) {
|
||||||
|
const data = loadSession(sessionId);
|
||||||
|
const out = [];
|
||||||
|
for (const agentId of Object.keys(data.pins || {})) {
|
||||||
|
const pin = pinOf(data, agentId);
|
||||||
|
if (!pin || pin.role !== "sleeper" || !pin.persona) continue;
|
||||||
|
if (slug && pin.persona !== slug) continue;
|
||||||
|
out.push({ agent_id: agentId, ...pin });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 回傳 { decision: "allow"|"deny"|"pass", reason }。"pass" = 不表態,交回原本流程。 */
|
/** 回傳 { decision: "allow"|"deny"|"pass", reason }。"pass" = 不表態,交回原本流程。 */
|
||||||
@@ -2921,8 +2958,15 @@ export function guardDecide(event) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
|
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人。
|
||||||
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]);
|
// pin 同時是 CLI 端 `--as-sleeper` 的授權憑證,所以角色一定要一起寫進去
|
||||||
|
// (舊格式那種沒有角色的 pin 也在這裡補上)。
|
||||||
|
{
|
||||||
|
const target = scope.pinned || info.personas[0];
|
||||||
|
if (agentId && target && (scope.pinned !== target || scope.pinnedRole !== "sleeper")) {
|
||||||
|
pinAgent(sessionId, agentId, target, "sleeper");
|
||||||
|
}
|
||||||
|
}
|
||||||
// sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定
|
// sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定
|
||||||
// (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。
|
// (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。
|
||||||
} else if (scope.role === "guest") {
|
} else if (scope.role === "guest") {
|
||||||
@@ -3003,7 +3047,7 @@ export function guardDecide(event) {
|
|||||||
`這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`,
|
`這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!scope.allowed && agentId) pinAgent(sessionId, agentId, slug);
|
if (agentId && (!scope.allowed || scope.pinnedRole !== "sleeper")) pinAgent(sessionId, agentId, slug, "sleeper");
|
||||||
if (MUTATING_TOOLS.has(tool)) {
|
if (MUTATING_TOOLS.has(tool)) {
|
||||||
return deny(
|
return deny(
|
||||||
`睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI,` +
|
`睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI,` +
|
||||||
@@ -3030,7 +3074,7 @@ export function guardDecide(event) {
|
|||||||
}
|
}
|
||||||
// 3) guest 唯讀 + first-touch pinning
|
// 3) guest 唯讀 + first-touch pinning
|
||||||
if (scope.role === "guest") {
|
if (scope.role === "guest") {
|
||||||
if (!scope.pinned && agentId) pinAgent(sessionId, agentId, slug);
|
if (agentId && (!scope.pinned || scope.pinnedRole !== "guest")) pinAgent(sessionId, agentId, slug, "guest");
|
||||||
if (MUTATING_TOOLS.has(tool)) {
|
if (MUTATING_TOOLS.has(tool)) {
|
||||||
return deny(
|
return deny(
|
||||||
`guest 人格 \`${slug}\` 在 sub agent 中為唯讀;要留下記憶請 \`persona.mjs remember --scope inbox\`(下次它自己載入時再固化)。`,
|
`guest 人格 \`${slug}\` 在 sub agent 中為唯讀;要留下記憶請 \`persona.mjs remember --scope inbox\`(下次它自己載入時再固化)。`,
|
||||||
|
|||||||
+35
-4
@@ -115,10 +115,41 @@ function requireSession(flags) {
|
|||||||
/**
|
/**
|
||||||
* 睡眠 sub agent 的寫入權:它替某個人格收尾,手上沒有 exclusive 鎖,只有 5 分鐘的 sleeper 租約。
|
* 睡眠 sub agent 的寫入權:它替某個人格收尾,手上沒有 exclusive 鎖,只有 5 分鐘的 sleeper 租約。
|
||||||
* 第一個收尾指令會把租約取起來(`acquireSleepLease` 內含鎖檢查:目標正被別的程序載入著就拒絕),
|
* 第一個收尾指令會把租約取起來(`acquireSleepLease` 內含鎖檢查:目標正被別的程序載入著就拒絕),
|
||||||
* 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 由 PreToolUse hook 把關,
|
* 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 先過 `requireSleeperPin()`:
|
||||||
* 只有 persona-sleeper 型的 sub agent 能用。
|
* 只有本 session 真的有一個 pin 在目標人格上的 persona-sleeper sub agent 才拿得到租約。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* `--as-sleeper` 的身分驗證——**CLI 自己驗,不外包給 hook**。
|
||||||
|
*
|
||||||
|
* 這個旗標的意思是「我是 persona-sleeper 型的 sub agent,正在替 <slug> 收尾」。
|
||||||
|
* 誰是 sub agent、是哪一型,只有 PreToolUse hook 看得到(`event.agent_type`),
|
||||||
|
* 而 hook 認得出這支 CLI 靠的是檔名正則——把 `persona.mjs` 複製出去改個名字,
|
||||||
|
* hook 就完全不表態,`--as-sleeper` 等於對任意人格的完整讀寫權。
|
||||||
|
*
|
||||||
|
* 所以唯一可信的證據是 hook 在 session 檔(`.runtime/sessions/<id>.json` 的 `pins`)
|
||||||
|
* 裡留下的 sleeper pin:它由 hook 依 `agent_type` 寫入,沒經過 hook 的程序拿不到。
|
||||||
|
* 這裡照那份資料驗:本 session 要有一個 pin 在目標人格上的 sleeper,驗不過就 die。
|
||||||
|
*/
|
||||||
|
function requireSleeperPin(slug, sessionId) {
|
||||||
|
const pins = pl.sleeperPins(sessionId, slug);
|
||||||
|
if (!pins.length) {
|
||||||
|
const others = pl.sleeperPins(sessionId).map((p) => p.persona);
|
||||||
|
die(
|
||||||
|
`\`--as-sleeper\` 不成立:本 session 沒有 pin 在 \`${slug}\` 上的 persona-sleeper sub agent` +
|
||||||
|
`${others.length ? `(目前有 pin 的是 ${JSON.stringify([...new Set(others)])})` : ""}。` +
|
||||||
|
"pin 由 PreToolUse hook 依 sub agent 的型別寫入,冒不得;" +
|
||||||
|
"要請人格收尾請走 /jsc-persona:persona-sleep,它會替那個人格開一個 sleeper。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const agentId = str(CURRENT_FLAGS["agent-id"]);
|
||||||
|
if (agentId && !pins.some((p) => p.agent_id === agentId)) {
|
||||||
|
die(`\`--agent-id ${agentId}\` 不是本 session 中 pin 在 \`${slug}\` 上的睡眠 sub agent。`);
|
||||||
|
}
|
||||||
|
return pins[0];
|
||||||
|
}
|
||||||
|
|
||||||
function sleeperAccess(slug, sessionId, agentId) {
|
function sleeperAccess(slug, sessionId, agentId) {
|
||||||
|
requireSleeperPin(slug, sessionId);
|
||||||
const mine = pl.liveSleepers(slug).find((s) => s.session_id === sessionId);
|
const mine = pl.liveSleepers(slug).find((s) => s.session_id === sessionId);
|
||||||
if (mine) {
|
if (mine) {
|
||||||
pl.heartbeatSleepLease(slug, sessionId, mine.agent_id ?? null);
|
pl.heartbeatSleepLease(slug, sessionId, mine.agent_id ?? null);
|
||||||
@@ -1240,8 +1271,8 @@ commands.leave = ({ flags }) => {
|
|||||||
delete data.guests[guest];
|
delete data.guests[guest];
|
||||||
const room = str(flags.room) || info.room;
|
const room = str(flags.room) || info.room;
|
||||||
pl.dropGuestLease(guest, session, room);
|
pl.dropGuestLease(guest, session, room);
|
||||||
for (const [agentId, slug] of Object.entries(data.pins || {})) {
|
for (const agentId of Object.keys(data.pins || {})) {
|
||||||
if (slug === guest) delete data.pins[agentId];
|
if (pl.pinOf(data, agentId)?.persona === guest) delete data.pins[agentId];
|
||||||
}
|
}
|
||||||
if (!Object.keys(data.guests).length) data.theater = false; // 沒有客人就退出劇場模式
|
if (!Object.keys(data.guests).length) data.theater = false; // 沒有客人就退出劇場模式
|
||||||
pl.saveSession(session, data);
|
pl.saveSession(session, data);
|
||||||
|
|||||||
+84
-2
@@ -1311,7 +1311,7 @@ check("sub agent 代睡:sleeper 租約可以完成整套收尾", (() => {
|
|||||||
|
|
||||||
// sleeper 的權限邊界(PreToolUse guard)
|
// sleeper 的權限邊界(PreToolUse guard)
|
||||||
const sleeperEvent = (extra) => ({ session_id: S_SLEEP2, agent_id: A_SLEEPER, agent_type: "jsc-persona:persona-sleeper", ...extra });
|
const sleeperEvent = (extra) => ({ session_id: S_SLEEP2, agent_id: A_SLEEPER, agent_type: "jsc-persona:persona-sleeper", ...extra });
|
||||||
pl.pinAgent(S_SLEEP2, A_SLEEPER, "epsilon");
|
pl.pinAgent(S_SLEEP2, A_SLEEPER, "epsilon", "sleeper");
|
||||||
check("sleeper 讀自己的檔案 → 放行",
|
check("sleeper 讀自己的檔案 → 放行",
|
||||||
guard(sleeperEvent({ tool_name: "Read", tool_input: { file_path: `${H}/epsilon/SOUL.md` } })) === "pass");
|
guard(sleeperEvent({ tool_name: "Read", tool_input: { file_path: `${H}/epsilon/SOUL.md` } })) === "pass");
|
||||||
check("sleeper 讀別的人格(含叫它來的主人格)→ 攔下",
|
check("sleeper 讀別的人格(含叫它來的主人格)→ 攔下",
|
||||||
@@ -1332,7 +1332,7 @@ check("sleeper 被 pin 住之後不得換人睡",
|
|||||||
tool_input: { command: `node persona.mjs sleep --persona alpha --session ${S_SLEEP2}` } })) === "deny");
|
tool_input: { command: `node persona.mjs sleep --persona alpha --session ${S_SLEEP2}` } })) === "deny");
|
||||||
// 迴歸:主人格載入著 alpha 時請 epsilon 去睡——sleeper 的範圍看它自己的 pin,不看 host
|
// 迴歸:主人格載入著 alpha 時請 epsilon 去睡——sleeper 的範圍看它自己的 pin,不看 host
|
||||||
const hostSleeper = "agent-sleeper-host";
|
const hostSleeper = "agent-sleeper-host";
|
||||||
pl.pinAgent(S_HOST, hostSleeper, "epsilon");
|
pl.pinAgent(S_HOST, hostSleeper, "epsilon", "sleeper");
|
||||||
const hostSleeperEvent = (extra) =>
|
const hostSleeperEvent = (extra) =>
|
||||||
({ session_id: S_HOST, agent_id: hostSleeper, agent_type: "jsc-persona:persona-sleeper", ...extra });
|
({ session_id: S_HOST, agent_id: hostSleeper, agent_type: "jsc-persona:persona-sleeper", ...extra });
|
||||||
check("主人格已載入別的人格時,sleeper 仍能跑自己的判斷式收尾",
|
check("主人格已載入別的人格時,sleeper 仍能跑自己的判斷式收尾",
|
||||||
@@ -1359,6 +1359,88 @@ check("sleeper 可以寫 diary 型別的長期記憶", (() => {
|
|||||||
check("主程序自己帶 --as-sleeper → 攔下(那是 sub agent 的身分)",
|
check("主程序自己帶 --as-sleeper → 攔下(那是 sub agent 的身分)",
|
||||||
guard({ session_id: S_HOST, tool_name: "Bash",
|
guard({ session_id: S_HOST, tool_name: "Bash",
|
||||||
tool_input: { command: `node persona.mjs consolidate --persona beta --session ${S_HOST} --as-sleeper` } }) === "deny");
|
tool_input: { command: `node persona.mjs consolidate --persona beta --session ${S_HOST} --as-sleeper` } }) === "deny");
|
||||||
|
|
||||||
|
// --- S1 迴歸:`--as-sleeper` 的授權要由 CLI 自己驗,不能外包給 hook ---------- //
|
||||||
|
// hook 認得出這支 CLI 靠檔名正則(/persona\.(mjs|js|py)\b/):把 scripts/ 複製出去、
|
||||||
|
// persona.mjs 改名成 p.mjs,hook 就整路不表態。若 CLI 不自己驗 pin,`--as-sleeper`
|
||||||
|
// 等於任何程序對任意人格的完整讀寫權。
|
||||||
|
const ROGUE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "persona-rogue-"));
|
||||||
|
const ROGUE_CLI = path.join(ROGUE_DIR, "p.mjs");
|
||||||
|
for (const f of fs.readdirSync(HERE)) {
|
||||||
|
if (f.endsWith(".mjs")) fs.copyFileSync(path.join(HERE, f), path.join(ROGUE_DIR, f === "persona.mjs" ? "p.mjs" : f));
|
||||||
|
}
|
||||||
|
const rogue = (args) => spawnSync(process.execPath, [ROGUE_CLI, ...args], { encoding: "utf8" });
|
||||||
|
check("改名後的 CLI 不再被 hook 認出來(所以 CLI 必須自己驗)",
|
||||||
|
pl.cliInvocation(`node ${ROGUE_CLI} remember --persona beta --session ${S_HOST} --as-sleeper`) === null &&
|
||||||
|
guard({ session_id: S_HOST, tool_name: "Bash",
|
||||||
|
tool_input: { command: `node ${ROGUE_CLI} remember --persona beta --session ${S_HOST} --as-sleeper` } }) === "pass");
|
||||||
|
check("沒有 sleeper pin 時 `--as-sleeper` 寫別人的記憶 → CLI 自己擋下", (() => {
|
||||||
|
const res = rogue(["remember", "--persona", "beta", "--session", S_HOST, "--as-sleeper",
|
||||||
|
"--role", "user", "--text", "偷寫進去的"]);
|
||||||
|
const st = path.join(H, "beta", "memory", "short-term.jsonl");
|
||||||
|
const body = fs.existsSync(st) ? fs.readFileSync(st, "utf8") : "";
|
||||||
|
return res.status !== 0 && String(res.stderr).includes("--as-sleeper") && !body.includes("偷寫進去的");
|
||||||
|
})());
|
||||||
|
check("沒有 sleeper pin 時 `--as-sleeper` 讀別人的記憶 → CLI 自己擋下", (() => {
|
||||||
|
const res = rogue(["recall", "--persona", "beta", "--session", S_HOST, "--as-sleeper", "--query", "記憶"]);
|
||||||
|
return res.status !== 0 && String(res.stderr).includes("persona-sleeper");
|
||||||
|
})());
|
||||||
|
check("沒有 sleeper pin 時 `--as-sleeper` 改別人的情緒 → CLI 自己擋下", (() => {
|
||||||
|
const before = JSON.stringify(pl.loadEmotion("beta").levels);
|
||||||
|
const res = rogue(["emotion", "--persona", "beta", "--session", S_HOST, "--as-sleeper",
|
||||||
|
"--apply", "joy=+40"]);
|
||||||
|
return res.status !== 0 && JSON.stringify(pl.loadEmotion("beta").levels) === before;
|
||||||
|
})());
|
||||||
|
check("`--as-sleeper` 也拿不到 sleeper 租約(驗不過就不該留下痕跡)",
|
||||||
|
pl.liveSleepers("beta").length === 0);
|
||||||
|
check("pin 在別的人格上的 sleeper 不能拿 `--as-sleeper` 碰第三個人格", (() => {
|
||||||
|
// 這個 session 有一個 pin 在 epsilon 上的 sleeper,但目標是 beta
|
||||||
|
const res = rogue(["recall", "--persona", "beta", "--session", S_SLEEP2, "--as-sleeper", "--query", "x"]);
|
||||||
|
return res.status !== 0 && String(res.stderr).includes("beta");
|
||||||
|
})());
|
||||||
|
check("guest pin 不能當成 sleeper pin 用(角色要對得上)", (() => {
|
||||||
|
const sess = "sess-pinrole-9191";
|
||||||
|
pl.pinAgent(sess, "agent-guest-x", "beta", "guest");
|
||||||
|
const res = rogue(["recall", "--persona", "beta", "--session", sess, "--as-sleeper", "--query", "x"]);
|
||||||
|
return res.status !== 0 && pl.sleeperPins(sess, "beta").length === 0;
|
||||||
|
})());
|
||||||
|
check("舊格式(純字串、沒有角色)的 pin 不算 sleeper 授權", (() => {
|
||||||
|
const sess = "sess-legacypin-9292";
|
||||||
|
const data = pl.loadSession(sess);
|
||||||
|
data.pins = { "agent-legacy": "beta" }; // 舊版寫下的格式
|
||||||
|
pl.saveSession(sess, data);
|
||||||
|
const parsed = pl.pinOf(pl.loadSession(sess), "agent-legacy");
|
||||||
|
const res = rogue(["recall", "--persona", "beta", "--session", sess, "--as-sleeper", "--query", "x"]);
|
||||||
|
return parsed.persona === "beta" && parsed.role === null &&
|
||||||
|
pl.sleeperPins(sess, "beta").length === 0 && res.status !== 0;
|
||||||
|
})());
|
||||||
|
check("真的有 sleeper pin 時,`--as-sleeper` 照常放行(沒弄壞正常流程)", (() => {
|
||||||
|
const sess = "sess-realsleeper-9393";
|
||||||
|
// hook 走一遍 first-touch pinning:它看得到 agent_type,所以會寫下 sleeper 角色
|
||||||
|
const decision = pl.guardDecide({
|
||||||
|
cwd: HERE, session_id: sess, agent_id: "agent-sleeper-real",
|
||||||
|
agent_type: "jsc-persona:persona-sleeper", tool_name: "Bash",
|
||||||
|
tool_input: { command: `node persona.mjs candidates --persona beta --session ${sess}` },
|
||||||
|
}).decision;
|
||||||
|
const pins = pl.sleeperPins(sess, "beta");
|
||||||
|
const res = cli(["candidates", "--persona", "beta", "--session", sess, "--as-sleeper"]);
|
||||||
|
const held = pl.liveSleepers("beta").some((s) => s.session_id === sess);
|
||||||
|
pl.dropSleepLease("beta", sess, null);
|
||||||
|
return decision === "pass" && pins.length === 1 && pins[0].agent_id === "agent-sleeper-real" &&
|
||||||
|
res.status === 0 && held;
|
||||||
|
})());
|
||||||
|
check("hook 的 first-touch pinning 會補上角色(舊 pin 也升級得了)", (() => {
|
||||||
|
const sess = "sess-upgradepin-9494";
|
||||||
|
const data = pl.loadSession(sess);
|
||||||
|
data.pins = { "agent-up": "beta" };
|
||||||
|
pl.saveSession(sess, data);
|
||||||
|
pl.guardDecide({
|
||||||
|
cwd: HERE, session_id: sess, agent_id: "agent-up", agent_type: "jsc-persona:persona-sleeper",
|
||||||
|
tool_name: "Read", tool_input: { file_path: `${H}/beta/SOUL.md` },
|
||||||
|
});
|
||||||
|
return pl.sleeperPins(sess, "beta").length === 1;
|
||||||
|
})());
|
||||||
|
fs.rmSync(ROGUE_DIR, { recursive: true, force: true });
|
||||||
check("關係節點換 id 不會長出同名的第二個節點", (() => {
|
check("關係節點換 id 不會長出同名的第二個節點", (() => {
|
||||||
cli(["load", "--persona", "alpha", "--session", S_HOST, "--takeover"]);
|
cli(["load", "--persona", "alpha", "--session", S_HOST, "--takeover"]);
|
||||||
cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--closeness", "40"]);
|
cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--closeness", "40"]);
|
||||||
|
|||||||
Reference in New Issue
Block a user