feat(agent): 完善权限与结果引用安全
This commit is contained in:
@@ -61,6 +61,34 @@ describe("Agent REST OpenAPI", () => {
|
||||
expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("separates automatic approval from persistent permission grants", () => {
|
||||
const document = generateAgentOpenApi();
|
||||
const runRequest = document.paths["/api/v1/agent/sessions/{session_id}/runs"]
|
||||
?.post?.requestBody;
|
||||
const permissionRequest = document.paths[
|
||||
"/api/v1/agent/sessions/{session_id}/permission-responses"
|
||||
]?.post?.requestBody;
|
||||
|
||||
expect(
|
||||
runRequest && !("$ref" in runRequest)
|
||||
? runRequest.content["application/json"]?.schema
|
||||
: undefined,
|
||||
).toMatchObject({
|
||||
properties: {
|
||||
approval_mode: { enum: ["request", "auto", "always"] },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
permissionRequest && !("$ref" in permissionRequest)
|
||||
? permissionRequest.content["application/json"]?.schema
|
||||
: undefined,
|
||||
).toMatchObject({
|
||||
properties: {
|
||||
reply: { enum: ["once", "always", "reject"] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("matches the public session runtime response shapes", () => {
|
||||
const document = generateAgentOpenApi();
|
||||
const schemas = document.components?.schemas ?? {};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("ResultReferenceResolver", () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
|
||||
store = new ResultReferenceStore(tempDir, 60_000);
|
||||
resolver = new ResultReferenceResolver(store);
|
||||
resolver = new ResultReferenceResolver(store, tempDir, 1024 * 1024);
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
@@ -193,6 +193,53 @@ describe("ResultReferenceResolver", () => {
|
||||
"DMA-2": "#00ff00",
|
||||
},
|
||||
});
|
||||
await expect(stat(filePath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects render payload files outside the configured import directory", async () => {
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), "tjwater-result-outside-"));
|
||||
const filePath = join(outsideDir, "render-wrapper.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
metadata: {},
|
||||
location: { file_path: filePath },
|
||||
data: { node_area_map: { J1: "DMA-1" } },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
resolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: "actor-4",
|
||||
clientSessionId: "client-4",
|
||||
projectKey: "project-key-4",
|
||||
sessionId: "session-4",
|
||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||
traceId: "trace-4",
|
||||
}),
|
||||
).rejects.toThrow("RESULT_REF_IMPORT_DIR");
|
||||
} finally {
|
||||
await rm(outsideDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oversized render payload files before parsing", async () => {
|
||||
const filePath = join(tempDir, "oversized.json");
|
||||
await writeFile(filePath, "x".repeat(128), "utf8");
|
||||
const sizeLimitedResolver = new ResultReferenceResolver(store, tempDir, 64);
|
||||
|
||||
await expect(
|
||||
sizeLimitedResolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: "actor-5",
|
||||
clientSessionId: "client-5",
|
||||
projectKey: "project-key-5",
|
||||
sessionId: "session-5",
|
||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||
traceId: "trace-5",
|
||||
}),
|
||||
).rejects.toThrow("RESULT_REF_IMPORT_MAX_BYTES");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: "已按始终允许模式放行",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -129,4 +129,31 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
"session.delete:warmup-session",
|
||||
]);
|
||||
});
|
||||
|
||||
it("submits question answers through the stable question API", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = {
|
||||
question: {
|
||||
reply: async (input: unknown) => {
|
||||
calls.push(input);
|
||||
return { data: { ok: true } };
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
clientPromise: null,
|
||||
closeServer: null,
|
||||
ensureClient: async () => client,
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
await runtime.replyQuestion({
|
||||
requestId: "question-1",
|
||||
sessionId: "session-1",
|
||||
answers: [["继续"]],
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ requestID: "question-1", answers: [["继续"]] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
describe("internal OpenCode permissions", () => {
|
||||
it("keeps protected paths denied in every approval mode", async () => {
|
||||
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
|
||||
permission?: Record<string, string | Record<string, string>>;
|
||||
};
|
||||
const permission = config.permission ?? {};
|
||||
const bash = permission.bash as Record<string, string> | undefined;
|
||||
const edit = permission.edit as Record<string, string> | undefined;
|
||||
const read = permission.read as Record<string, string> | undefined;
|
||||
|
||||
expect(permission["*"]).toBe("ask");
|
||||
expect(permission.external_directory).toBe("deny");
|
||||
expect(permission.question).toBe("allow");
|
||||
expect(permission.todowrite).toBe("allow");
|
||||
expect(read?.["*"]).toBe("allow");
|
||||
expect(read?.["data/**"]).toBe("deny");
|
||||
expect(read?.["**/logs/**"]).toBe("deny");
|
||||
expect(edit?.["*"]).toBe("ask");
|
||||
expect(edit?.["data/**"]).toBe("deny");
|
||||
expect(edit?.["**/logs/**"]).toBe("deny");
|
||||
expect(bash?.["*"]).toBe("ask");
|
||||
expect(bash?.["*.env*"]).toBe("deny");
|
||||
expect(bash?.["*data/*"]).toBe("deny");
|
||||
expect(bash?.["*logs/*"]).toBe("deny");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user