feat(agent): 完善权限与结果引用安全

This commit is contained in:
2026-08-06 18:41:52 +08:00
parent a5e91ac2b8
commit b19af8846a
22 changed files with 621 additions and 47 deletions
+153
View File
@@ -0,0 +1,153 @@
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
import express, { Router } from "express";
import type { Server } from "node:http";
import { CredentialRefreshCoordinator } from "../../src/auth/credentialRefresh.js";
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
import type { ActiveRun } from "../../src/routes/chatUiState.js";
describe("chat interaction routes", () => {
let baseUrl = "";
let server: Server;
const replyQuestion = mock(async () => ({ ok: true }));
const replyPermission = mock(async () => ({ ok: true }));
beforeAll(async () => {
const activeRuns = new Map<string, ActiveRun>();
activeRuns.set("runtime-session", {
clientSessionId: "client-session",
controller: new AbortController(),
messages: [
{
id: "assistant-1",
role: "assistant",
permissions: [
{
requestId: "permission-1",
sessionId: "runtime-session",
permission: "bash",
patterns: ["npm test"],
always: ["npm test"],
createdAt: 1,
status: "pending",
},
],
questions: [{ requestId: "question-1", status: "pending" }],
},
],
pendingPermissions: new Map([
[
"permission-1",
{
session_id: "runtime-session",
request_id: "permission-1",
permission: "bash",
patterns: ["npm test"],
always: ["npm test"],
created_at: 1,
},
],
]),
pendingQuestions: new Map([
[
"question-1",
{
created_at: 1,
request_id: "question-1",
session_id: "runtime-session",
questions: [],
},
],
]),
status: "running",
subscribers: new Set(),
});
const router = Router();
router.use((req, _res, next) => {
req.agentAuth = {
accessToken: "access-token",
userId: "user-1",
keycloakSub: "keycloak-1",
username: "tester",
role: "user",
isSuperuser: false,
projectId: "project-1",
network: "network-1",
projectRole: "member",
};
next();
});
registerChatInteractionRoutes(router, {
activeRuns,
credentialRefreshCoordinator: new CredentialRefreshCoordinator(),
runtime: { replyPermission, replyQuestion } as never,
sessionMetadataStore: {
get: async () => ({ sessionId: "runtime-session" }),
} as never,
sessionUiStateStore: {
read: async () => null,
write: async () => undefined,
} as never,
});
const app = express();
app.use(express.json());
app.use(router);
server = app.listen(0);
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not expose a TCP port");
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(() => {
server.close();
});
it("submits answers to the stable OpenCode question adapter", async () => {
const response = await fetch(
`${baseUrl}/sessions/client-session/question-responses`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: "question-1",
action: "reply",
answers: [["继续"]],
}),
},
);
expect(response.status).toBe(202);
expect(replyQuestion).toHaveBeenCalledWith({
requestId: "question-1",
sessionId: "runtime-session",
answers: [["继续"]],
});
});
it("forwards saved permission grants to OpenCode", async () => {
const response = await fetch(
`${baseUrl}/sessions/client-session/permission-responses`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: "permission-1",
reply: "always",
}),
},
);
expect(response.status).toBe(202);
expect(replyPermission).toHaveBeenCalledWith({
requestId: "permission-1",
sessionId: "runtime-session",
reply: "always",
message: undefined,
});
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "bun:test";
import {
canAutoApprovePermission,
resolvePermissionApproval,
} from "../../src/routes/chatPermissionPolicy.js";
describe("permission approval policy", () => {
it.each([
"show_chart",
"web_search",
"tjwater_server_query",
"tjwater_tjwater_server_query",
])("allows low-risk permission %s", (permission) => {
expect(canAutoApprovePermission(permission)).toBe(true);
});
it.each(["bash", "edit", "external_directory", "store_render_ref"])(
"requires confirmation for permission %s",
(permission) => {
expect(canAutoApprovePermission(permission)).toBe(false);
},
);
it("resolves request, auto, and always modes", () => {
expect(resolvePermissionApproval("request", "show_chart").autoApprove).toBe(false);
expect(resolvePermissionApproval("auto", "show_chart").autoApprove).toBe(true);
expect(resolvePermissionApproval("auto", "bash").autoApprove).toBe(false);
expect(resolvePermissionApproval("always", "bash")).toMatchObject({
autoApprove: true,
title: "已按始终允许模式放行",
});
});
});
+91 -8
View File
@@ -61,7 +61,7 @@ describe("streamPromptResponse", () => {
} satisfies Partial<PermissionRequestPayload>);
});
it("auto replies always when approval mode is always", async () => {
it("auto approves an allowlisted low-risk permission once", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
@@ -71,10 +71,10 @@ describe("streamPromptResponse", () => {
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
permission: "tjwater_tjwater_server_query",
patterns: ["*"],
metadata: {},
always: ["*"],
},
},
{
@@ -97,7 +97,7 @@ describe("streamPromptResponse", () => {
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "always",
approvalMode: "auto",
write: (event, data) => events.push({ event, data }),
});
@@ -105,17 +105,100 @@ describe("streamPromptResponse", () => {
{
requestId: "perm-1",
sessionId: "runtime-session-1",
reply: "always",
reply: "once",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "perm-1",
reply: "always",
reply: "once",
});
});
it("keeps high-risk permissions interactive in auto mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-auto-bash",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "auto",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([]);
expect(events.find((item) => item.event === "permission_request")?.data).toMatchObject({
request_id: "perm-auto-bash",
permission: "bash",
});
});
it("approves every OpenCode ask once in always mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-always-bash",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "always",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([
{
requestId: "perm-always-bash",
sessionId: "runtime-session-1",
reply: "once",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
});
it("forwards opencode v2 permission requests as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>