第二步:让 ZCode 上的 Bash/Write/Edit 授权走 AgentMail 的人工审批,
而不是只靠本地界面。
钩子契约从 CLI 产物里逆出来(不猜协议):
- 输入走 stdin:{hook_event_name, tool_name, tool_input, session_id, permission_mode…}
- 输出走 stdout,schema **严格**:{"decision":"approve"} / {"decision":"block","reason"}
多一个键就会报 "Hook stdout failed HookJSONOutput schema validation"
- 空输出 / 不以 { 开头 = 不表态;exit 2 = 拒绝;其它非零 = 钩子失败
- 注入的环境变量含 ZCODE_PLUGIN_ROOT / ZCODE_PLUGIN_DATA / ZCODE_SESSION_ID
(MCP 配置里用 ZCODE_SESSION_ID 反而会抛「需要运行时会话上下文」)
档位判定与 pi 桥逐条对齐(plan 直接拒 / workspace 问人 / full 批准),
判定逻辑抽成纯函数 lib/hook-policy.mjs 以便穷举:
其中 full 档必须**返回批准而不是不表态** —— 钩子一旦触发说明 ZCode 本会去问人,
不表态等于让那个询问照常发生,full 档就退化成了 workspace 档。
钩子自己开 SSE 等决定,不依赖桥进程:网关的 SSE 是扇出的
(clients 按唯一 id 存,SendToAgent 推给该 Agent 的所有客户端),
一次性进程也能订阅到自己那条 permission_decision。这样交互模式下同样可用
(人自己开着 ZCode 干活时并没有桥在跑)。先建连再发请求是有意的:
反过来会有一个窗口,人在窗口内点的同意推送给当时还不存在的客户端。
fail closed 但区分模式:永久失败(409/4xx)一律拒绝;暂时失败在
AGENTMAIL_SESSION_ID 非空(邮件驱动、没有本地界面兜底)时拒绝,
交互模式则不表态让人就地决定。
「一直同意」落盘(lib/grants-file.mjs):钩子是一个事件一个进程,
不落盘那个选项就是骗人的。判定仍交给共用的 permission-grants.js。
共用模块同源范围扩到 9 个(新增 permission-mode / relay-key /
permission-grants / sse-client)—— 档位语义与决策判定分叉会让「同意」
在 ZCode 上悄悄变成另一种意思。
验证:
- 单元 229/229(新增 hook-policy 14 项、grants-file 8 项,含反向对照)
- 共用模块四方同源检查通过
- 授权桥端到端 5/5,全部带反向对照:
同意→approve;拒绝→block 且原因必须来自人的拒绝(不能是超时兜底);
plan 档拒绝且**不产生**任何权限邮件;无人可问(409)→fail closed;
非守卫工具→不表态
- `zcode plugins list` → agentmail@inline [enabled],hooks: 1,
mcp: plugin:agentmail:agentmail
我自己写错的两处判据(都已修,值得记下):
1. 待决权限列表里有历史积压(实测 6 条,含其它 Agent 的条目),
只按「第一条新的」取会拿到无关请求 —— 于是人点了同意而钩子在等自己那条,
最后超时。第一版还把这个超时误报成「拒绝路径通过」。
现在按「启动前快照差集 + session_id + agent_name」三重过滤。
2. 「无人可问」控制组最初传了个非 UUID 的 session id,走的是 400(参数错),
验不到 409 那条真实路径。改为真的造一条只有 Agent 没有人类的会话。
380 lines
16 KiB
JavaScript
380 lines
16 KiB
JavaScript
#!/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 { 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,
|
||
AGENTMAIL_PERMISSION_WAIT_MS: '60000'
|
||
};
|
||
|
||
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 {
|
||
// 造一条**只有 Agent、没有人类**的会话(zcode → pi),这才是真实的 409 场景:
|
||
// 服务端按 会话 owner → 线索里最近的人类 解析不出决策人。
|
||
// 传个不存在的 session id 会走 400(参数错),验不到这条路径。
|
||
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: 'pi',
|
||
subject: `无人可问控制组 ${Date.now()}`,
|
||
body: '仅用于验证:这条会话上没有人类。'
|
||
})
|
||
});
|
||
const noHumanSession = (await res.json().catch(() => ({})))?.session_id;
|
||
if (!noHumanSession) {
|
||
record('反向对照 · 无人可问 → 拒绝', '无法判定', '未能造出无人类的会话');
|
||
} else {
|
||
const r = await runHook({
|
||
input: hookInput('Bash', `toolu-nohuman-${Date.now()}`),
|
||
env: {
|
||
...baseEnv,
|
||
AGENTMAIL_SESSION_ID: noHumanSession,
|
||
AGENTMAIL_PERMISSION_MODE: 'workspace'
|
||
},
|
||
timeoutMs: 60000
|
||
});
|
||
if (r.parsed?.decision === 'block') {
|
||
record('反向对照 · 无人可问 → 拒绝', '通过', (r.parsed.reason || '').split('\n')[0].slice(0, 70));
|
||
} 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);
|
||
});
|