#!/usr/bin/env node /** * 授权钩子的端到端验证 —— 不需要 ZCode 登录也能跑。 * * 为什么要单独验这一层:钩子是本插件里**唯一会放行危险工具**的地方。 * 单测能证明「档位判定正确」,但证明不了「人点了同意,钩子真的收到了那条决定」—— * 中间隔着 SSE 扇出、relay_key 配对、决策文案判定三处真实耦合。 * * # 判据设计 * * - **正向**:真建一条含人类的会话 → 起钩子 → 人点「同意」→ 钩子必须输出 approve * - **反向对照 1**:同一路径改点「拒绝」→ 必须输出 block(证明它不是恒 approve) * - **反向对照 2**:plan 档 → 必须**不产生任何权限邮件**就拒绝(证明档位真的在拦) * - **反向对照 3**:无人可问(无会话)→ 必须 fail closed 拒绝 * - **反向对照 4**:非守卫工具(Read)→ 必须不表态(证明不是恒输出) * * 三态结论:某一项无法判定时明确报「无法判定」,不并入通过。 * * 用法:node test/manual/permission-e2e.mjs [--keep] */ import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const HOOK = join(HERE, '../../hooks/permission.mjs'); const GATEWAY = process.env.GATEWAY || 'http://127.0.0.1:8180'; const HUMAN = { username: 'gui-lab', password: 'gui123456' }; const KEEP = process.argv.includes('--keep'); const results = []; const record = (name, state, detail) => { results.push({ name, state, detail }); const icon = state === '通过' ? '✓' : state === '失败' ? '✗' : '?'; console.log(` ${icon} ${name}${detail ? ` —— ${detail}` : ''}`); }; async function login() { const res = await fetch(`${GATEWAY}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(HUMAN) }); if (!res.ok) throw new Error(`登录失败 HTTP ${res.status}`); const cookie = (res.headers.getSetCookie?.() ?? []).map(c => c.split(';')[0]).join('; '); if (!cookie) throw new Error('登录成功但没拿到 cookie'); return cookie; } /** agent 身份发一封邮件,得到一条含人类的会话。 */ async function openSession(agent) { const res = await fetch(`${GATEWAY}/api/v1/mail/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Agent-Name': agent.name, ...(agent.key ? { Authorization: `Bearer ${agent.key}` } : { 'X-Agent-Secret': agent.secret }) }, body: JSON.stringify({ to: HUMAN.username, subject: `授权桥验证 ${new Date().toISOString().slice(11, 19)}`, body: '这是一封用于验证授权钩子的邮件,无需处理。' }) }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.session_id) { throw new Error(`建会话失败 HTTP ${res.status} ${JSON.stringify(data).slice(0, 200)}`); } return { sessionId: data.session_id, mailId: data.mail_id, subject: `授权桥验证` }; } /** * 以人类身份等一条**属于本会话**的待决权限。 * * 必须同时按「不在启动前快照里」+「session_id 是本会话」+「agent 是 zcode」三重过滤: * `/permission/pending` 返回的是所有历史待决请求(本机实测积压了 6 条, * 里面还有 pi 桥的小写 `bash` 条目)。只按「第一条新的」取,会取到一条**无关**的 * 旧请求 —— 于是人在界面上点了同意,钩子却在等自己那条,最后超时。 * 第一版脚本就是这么错的:它把「钩子超时」报成了「拒绝路径通过」。 */ async function waitPending(cookie, timeoutMs, { before, sessionId } = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const res = await fetch(`${GATEWAY}/api/v1/permission/pending`, { headers: { Cookie: cookie } }); if (res.ok) { const { requests } = await res.json().catch(() => ({ requests: [] })); const fresh = (requests || []).find( r => !before?.has(r.mail_id) && r.agent_name === 'zcode' && (!sessionId || r.session_id === sessionId) ); if (fresh) return fresh; } await new Promise(r => setTimeout(r, 400)); } return null; } async function decide(cookie, mailId, decision) { const res = await fetch(`${GATEWAY}/api/v1/permission/decide`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie }, body: JSON.stringify({ mail_id: mailId, decision, note: `e2e:${decision}` }) }); const body = await res.text(); return { ok: res.ok, status: res.status, body: body.slice(0, 200) }; } /** * 跑一次钩子。 * * 返回值区分三种情况:输出 approve / 输出 block / 没有输出(不表态)。 * 把「进程崩了」与「明确拒绝」分开记 —— 两者在业务上后果完全不同。 */ function runHook({ input, env, timeoutMs = 90000 }) { return new Promise(resolve => { const child = spawn(process.execPath, [HOOK], { env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] }); let out = ''; let err = ''; let done = false; const finish = () => { if (done) return; done = true; const trimmed = out.trim(); let parsed = null; if (trimmed) { try { parsed = JSON.parse(trimmed); } catch { parsed = { __unparsable: trimmed.slice(0, 200) }; } } resolve({ stdout: trimmed, parsed, stderr: err.trim().split('\n').slice(-4).join('\n'), code: child.exitCode }); }; child.stdout.on('data', d => (out += d)); child.stderr.on('data', d => (err += d)); child.on('close', () => { if (!done) { // close 之后 exitCode 才是最终值;这里直接读即可 finish(); } }); const timer = setTimeout(() => { child.kill('SIGKILL'); finish(); }, timeoutMs); child.on('close', () => clearTimeout(timer)); child.stdin.end(JSON.stringify(input)); }); } const hookInput = (toolName, toolUseId) => ({ hook_event_name: 'PermissionRequest', tool_name: toolName, tool_input: { command: 'echo e2e' }, session_id: 'zcode-session-probe', tool_use_id: toolUseId, permission_mode: 'default', cwd: '/tmp' }); async function main() { const agent = { name: 'zcode' }; // 密钥从部署环境读;没有就退回 secret try { agent.key = readFileSync('/etc/agentmail/zcode.env', 'utf8').match( /AGENTMAIL_AGENT_KEY=(.+)/ )?.[1]?.trim(); } catch {} if (!agent.key) { try { agent.secret = readFileSync('/root/gotmp/zcode-agent-secret.txt', 'utf8').trim(); } catch {} } if (!agent.key && !agent.secret) { console.error('拿不到 zcode 的凭据,无法验证'); process.exit(2); } const cookie = await login(); console.log('已登录人类账号,开始验证\n'); const grantsFile = join(await mkdtemp(join(tmpdir(), 'zc-e2e-')), 'grants.json'); const baseEnv = { AGENTMAIL_GATEWAY_URL: GATEWAY, AGENTMAIL_AGENT_NAME: 'zcode', ...(agent.key ? { AGENTMAIL_AGENT_KEY: agent.key } : { AGENTMAIL_AGENT_SECRET: agent.secret }), AGENTMAIL_ZCODE_GRANTS_FILE: grantsFile, // 必须**明显小于**下面 runHook 的杀进程上限:两者相等时钩子会在 // 正要输出结论的瞬间被 SIGKILL,于是「不表态」与「来不及答」分不开。 AGENTMAIL_PERMISSION_WAIT_MS: '20000' }; const seen = new Set(); // 启动前先快照一次:待决列表里有历史积压(含其他 Agent 的条目), // 不排除掉就会把「别人的旧请求」当成我们自己刚建的。 { const res = await fetch(`${GATEWAY}/api/v1/permission/pending?all=true`, { headers: { Cookie: cookie } }); const { requests } = await res.json().catch(() => ({ requests: [] })); for (const r of requests || []) seen.add(r.mail_id); console.log(`启动前已有 ${seen.size} 条历史待决请求(已排除)\n`); } // ── 1. 正向:workspace 档 + 同意 ───────────────────────────── try { const { sessionId, subject } = await openSession(agent); const hookPromise = runHook({ input: hookInput('Bash', `toolu-approve-${Date.now()}`), env: { ...baseEnv, AGENTMAIL_SESSION_ID: sessionId, AGENTMAIL_PERMISSION_MODE: 'workspace', AGENTMAIL_MAIL_SUBJECT: subject } }); const pending = await waitPending(cookie, 30000, { before: seen, sessionId }); if (!pending) { record('workspace 档 · 同意 → approve', '无法判定', '30 秒内没等到本会话的待决权限邮件'); } else { seen.add(pending.mail_id); const d = await decide(cookie, pending.mail_id, '同意'); const r = await hookPromise; if (!d.ok) { record('workspace 档 · 同意 → approve', '无法判定', `决策接口 HTTP ${d.status}`); } else if (r.parsed?.decision === 'approve') { record('workspace 档 · 同意 → approve', '通过', '钩子收到决定并放行'); } else { record('workspace 档 · 同意 → approve', '失败', `钩子输出 ${r.stdout || '(空)'} code=${r.code}`); } } } catch (e) { record('workspace 档 · 同意 → approve', '失败', e.message); } // ── 2. 反向对照:同一路径改点拒绝 ──────────────────────────── try { const { sessionId, subject } = await openSession(agent); const hookPromise = runHook({ input: hookInput('Bash', `toolu-deny-${Date.now()}`), env: { ...baseEnv, AGENTMAIL_SESSION_ID: sessionId, AGENTMAIL_PERMISSION_MODE: 'workspace', AGENTMAIL_MAIL_SUBJECT: subject } }); const pending = await waitPending(cookie, 30000, { before: seen, sessionId }); if (!pending) { record('反向对照 · 拒绝 → block', '无法判定', '没等到本会话的待决权限邮件'); } else { seen.add(pending.mail_id); await decide(cookie, pending.mail_id, '拒绝'); const r = await hookPromise; // 判据必须验**原因来自人的拒绝**,不能只验「输出是 block」—— // 超时也会输出 block。第一版就因此把超时误报成了通过。 if (r.parsed?.decision === 'block' && /拒绝/.test(r.parsed.reason || '')) { record('反向对照 · 拒绝 → block', '通过', '钩子把人的拒绝转成了拒绝'); } else if (r.parsed?.decision === 'block') { record( '反向对照 · 拒绝 → block', '失败', `block 但不是人拒绝造成的(原因:${(r.parsed.reason || '').slice(0, 50)})` ); } else { record('反向对照 · 拒绝 → block', '失败', `钩子输出 ${r.stdout || '(空)'}`); } } } catch (e) { record('反向对照 · 拒绝 → block', '失败', e.message); } // ── 3. 反向对照:plan 档必须不产生邮件就拒绝 ───────────────── try { // 隔离一个全新会话:不然「有没有产生新请求」会被别处的请求干扰, // 而判据一旦看错对象,就会把「别人的旧请求」当成我们的泄漏(第一版即如此)。 const { sessionId } = await openSession(agent); const r = await runHook({ input: hookInput('Bash', `toolu-plan-${Date.now()}`), env: { ...baseEnv, AGENTMAIL_SESSION_ID: sessionId, AGENTMAIL_PERMISSION_MODE: 'plan' }, timeoutMs: 20000 }); if (r.parsed?.decision !== 'block') { record('反向对照 · plan 档直接拒绝', '失败', `钩子输出 ${r.stdout || '(空)'}`); } else if (!/plan 档/.test(r.parsed.reason || '')) { record('反向对照 · plan 档直接拒绝', '失败', `拒绝原因不是档位判定:${r.parsed.reason}`); } else { const leaked = await waitPending(cookie, 3000, { before: seen, sessionId }); if (leaked) { seen.add(leaked.mail_id); record('反向对照 · plan 档直接拒绝', '失败', `仍产生了权限邮件 ${leaked.mail_id}`); } else { record('反向对照 · plan 档直接拒绝', '通过', '按档位拒绝,且未给任何人发权限邮件'); } } } catch (e) { record('反向对照 · plan 档直接拒绝', '失败', e.message); } // ── 4. 反向对照:无人可问 → fail closed ───────────────────── try { // 用「**合法 UUID 但不存在**的会话」。实测服务端稳定回 409: // {"error":"权限询问无法送达:该任务链上没有人类用户", …} // // 为什么不真的造一条「只有 Agent、没有人类」的会话:那不可靠 —— // 发信可能落进一条**已有会话**(实测 zcode→pi 落进了带 gui-lab 的会话), // 于是服务端正常受理、钩子开始等人,而人不会来,最后得到的是超时 block。 // 那样这条控制组就验不到 409 那条路径,还容易被误读成通过。 const ghost = randomUUID(); const r = await runHook({ input: hookInput('Bash', `toolu-nohuman-${Date.now()}`), env: { ...baseEnv, AGENTMAIL_SESSION_ID: ghost, AGENTMAIL_PERMISSION_MODE: 'workspace' }, timeoutMs: 60000 }); if (r.parsed?.decision === 'block' && /没有人类|无人可问|无法送达/.test(r.parsed.reason || '')) { record('反向对照 · 无人可问 → 拒绝', '通过', (r.parsed.reason || '').split('\n')[0].slice(0, 60)); } else if (r.parsed?.decision === 'block') { // 时间到也是 block,但那不是这条控制组要验的东西 —— 明确报「无法判定」 record('反向对照 · 无人可问 → 拒绝', '无法判定', `block 但不是 409 造成的:${(r.parsed.reason || '').slice(0, 50)}`); } else if (r.parsed === null) { record('反向对照 · 无人可问 → 拒绝', '失败', '不表态等于放行(邮件驱动下不允许)'); } else { record('反向对照 · 无人可问 → 拒绝', '失败', `钩子输出了 ${r.stdout}`); } } catch (e) { record('反向对照 · 无人可问 → 拒绝', '失败', e.message); } // ── 5. 反向对照:非守卫工具不表态 ─────────────────────────── try { const r = await runHook({ input: hookInput('Read', `toolu-read-${Date.now()}`), env: { ...baseEnv, AGENTMAIL_PERMISSION_MODE: 'workspace' }, timeoutMs: 20000 }); if (r.parsed === null && r.code === 0) { record('反向对照 · Read 不表态', '通过', '无输出即无意见(退回 ZCode 自己的流程)'); } else { record('反向对照 · Read 不表态', '失败', `输出 ${r.stdout || '(空)'} code=${r.code}`); } } catch (e) { record('反向对照 · Read 不表态', '失败', e.message); } // ── 小结 ──────────────────────────────────────────────────── const pass = results.filter(r => r.state === '通过').length; const fail = results.filter(r => r.state === '失败').length; const unknown = results.filter(r => r.state === '无法判定').length; console.log(`\n结果:${pass} 通过 / ${fail} 失败 / ${unknown} 无法判定`); if (!KEEP) await rm(join(grantsFile, '..'), { recursive: true, force: true }); process.exit(fail > 0 ? 1 : 0); } main().catch(e => { console.error('验证脚本自身出错:', e); process.exit(2); });