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
+1 -1
View File
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: description:
"在前端地图上对 junctions 图层应用分区渲染。使用前必须完成两步:① 准备数据结构(JSON 文件,结构为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node idvalue 是 area id);② 调用 store_render_ref 将 JSON 文件存储到受控路径,获取 render_ref(格式为 res-...);③ 将 render_ref 传入本工具完成前端渲染。注意:不要先把 ref 内容完整读出再传给前端,也不要直接传本地文件路径。", "在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIRlocation.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。",
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
+4 -2
View File
@@ -3,10 +3,12 @@ import { tool } from "@opencode-ai/plugin";
const internalBaseUrl = const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const importDirectory =
process.env.RESULT_REF_IMPORT_DIR ?? "./data/result-imports";
export default tool({ export default tool({
description: description:
"将本地 JSON 渲染数据文件存储到受控路径,返回可供 render_junctions 使用的 render_refres-...)。前置步骤:先准备好符合 render_junctions 数据结构的 JSON 文件 { node_area_map, area_ids?, area_colors? },写入本地路径后再调用本工具传入该路径,获取 render_ref 后传给 render_junctions 完成前端渲染。", `导入 ${importDirectory} 下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } }location.file_path 必须与传入的绝对路径完全一致。只接受该目录内的真实文件,不接受目录外路径或指向目录外的符号链接。`,
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
@@ -16,7 +18,7 @@ export default tool({
file_path: tool.schema file_path: tool.schema
.string() .string()
.describe( .describe(
"本地 JSON 文件绝对路径,内容为 render_junctions 所需的数据结构 { node_area_map, area_ids?, area_colors? }。", `位于 ${importDirectory} 内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 datadata 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。`,
), ),
}, },
async execute(args, context) { async execute(args, context) {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM smanx/opencode:1.18.4@sha256:719821683648f5c28431ed28a068cb96f92c15374148783a1e0d34d5e2f16ed3 AS base FROM smanx/opencode:1.18.13@sha256:b976acda21efffacd44abd7847dac7d646910dbaa477d1877e2881b39cf22a91 AS base
USER root USER root
ARG UBUNTU_APT_MIRROR= ARG UBUNTU_APT_MIRROR=
ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
+6
View File
@@ -86,6 +86,12 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000
`opencode.json` 已启用 `experimental.continue_loop_on_deny`。用户拒绝权限请求后,OpenCode V1 会把拒绝结果交还给 Agent,让其尝试无需该权限的替代方案,而不是直接结束本轮执行。 `opencode.json` 已启用 `experimental.continue_loop_on_deny`。用户拒绝权限请求后,OpenCode V1 会把拒绝结果交还给 Agent,让其尝试无需该权限的替代方案,而不是直接结束本轮执行。
前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,其他权限请求逐次交给用户确认;“自动批准”额外自动放行低风险业务工具,其他请求仍需确认;“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。
单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。外部目录以及 `.env``data/``logs/` 路径仍由静态配置明确禁止,三种整体模式都不能绕过这些拒绝规则。
`store_render_ref` 只会从 `RESULT_REF_IMPORT_DIR`(默认 `./data/result-imports`)导入包装格式 JSON。文件必须包含 `metadata``location.file_path``data`,且真实路径不能越出导入目录;单文件默认上限为 64 MiB,成功导入后源包装文件会被删除。
## 配置与安全 ## 配置与安全
不要提交 `.env``.local.env``data/``logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。 不要提交 `.env``.local.env``data/``logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。
+3 -1
View File
@@ -1011,8 +1011,10 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"request", "request",
"auto",
"always" "always"
] ],
"description": "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode."
} }
}, },
"required": [ "required": [
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"agent": { "agent": {
"file": "agent-v1.openapi.json", "file": "agent-v1.openapi.json",
"sha256": "d559c6e76c33e7a7451743f60d85da0630d0df14fb5215228acefcf2eaea555a" "sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
} }
} }
} }
+40 -4
View File
@@ -13,10 +13,36 @@
"port": 4096 "port": 4096
}, },
"permission": { "permission": {
"*": "ask",
"external_directory": "deny",
"read": {
"*": "allow", "*": "allow",
"external_directory": "ask", ".env": "deny",
".env.*": "deny",
"*.env": "deny",
"**/.env": "deny",
"**/.env.*": "deny",
"**/*.env": "deny",
"data/**": "deny",
"**/data/**": "deny",
"logs/**": "deny",
"**/logs/**": "deny"
},
"edit": {
"*": "ask",
".env": "deny",
".env.*": "deny",
"*.env": "deny",
"**/.env": "deny",
"**/.env.*": "deny",
"**/*.env": "deny",
"data/**": "deny",
"**/data/**": "deny",
"logs/**": "deny",
"**/logs/**": "deny"
},
"bash": { "bash": {
"*": "allow", "*": "ask",
"rm *": "ask", "rm *": "ask",
"rmdir *": "ask", "rmdir *": "ask",
"mv *": "ask", "mv *": "ask",
@@ -24,9 +50,19 @@
"chown *": "ask", "chown *": "ask",
"sudo *": "ask", "sudo *": "ask",
"curl *": "ask", "curl *": "ask",
"wget *": "ask" "wget *": "ask",
"*.env*": "deny",
"*data/*": "deny",
"* data": "deny",
"*/data": "deny",
"*logs/*": "deny",
"* logs": "deny",
"*/logs": "deny"
}, },
"edit": "ask" "question": "allow",
"todo": "allow",
"todoread": "allow",
"todowrite": "allow"
}, },
"experimental": { "experimental": {
"continue_loop_on_deny": true "continue_loop_on_deny": true
+8
View File
@@ -103,6 +103,14 @@ const envSchema = z
LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8), LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
// result_ref 持久化存储目录。 // result_ref 持久化存储目录。
RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"), RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"),
// 仅允许 store_render_ref 从该目录导入受控 JSON 包装文件。
RESULT_REF_IMPORT_DIR: z.string().default("./data/result-imports"),
// 单个渲染包装 JSON 的最大导入字节数。
RESULT_REF_IMPORT_MAX_BYTES: z.coerce
.number()
.int()
.positive()
.default(64 * 1024 * 1024),
// result_ref 保留时长(小时)。 // result_ref 保留时长(小时)。
RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168), RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168),
// 定时清理过期 result_ref 的扫描周期(毫秒)。 // 定时清理过期 result_ref 的扫描周期(毫秒)。
+6 -1
View File
@@ -214,7 +214,12 @@ register("/api/v1/agent/sessions/{session_id}/runs", "post", {
schema: z.object({ schema: z.object({
message: z.string().min(1).max(10000), message: z.string().min(1).max(10000),
model: z.string().optional(), model: z.string().optional(),
approval_mode: z.enum(["request", "always"]).optional(), approval_mode: z
.enum(["request", "auto", "always"])
.optional()
.describe(
"request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode.",
),
}), }),
}, },
}, },
+42 -4
View File
@@ -1,4 +1,7 @@
import { readJsonFile } from "../utils/fileStore.js"; import { realpath, stat } from "node:fs/promises";
import { isAbsolute, relative } from "node:path";
import { readJsonFile, removeFileIfExists } from "../utils/fileStore.js";
import { import {
type ResultReferenceKind, type ResultReferenceKind,
type ResultReferenceRecord, type ResultReferenceRecord,
@@ -33,7 +36,11 @@ export type RenderJunctionPayload = {
}; };
export class ResultReferenceResolver { export class ResultReferenceResolver {
constructor(private readonly store: ResultReferenceStore) {} constructor(
private readonly store: ResultReferenceStore,
private readonly importRoot: string,
private readonly importMaxBytes: number,
) {}
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。 // Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
async register(input: RegisterResultReferenceInput) { async register(input: RegisterResultReferenceInput) {
@@ -63,7 +70,17 @@ export class ResultReferenceResolver {
filePath: string, filePath: string,
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">, input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
) { ) {
const raw = await readJsonFile<unknown>(filePath); const resolvedFilePath = await resolvePathInsideRoot(filePath, this.importRoot);
const fileStat = await stat(resolvedFilePath);
if (!fileStat.isFile()) {
throw new Error("render payload path must point to a regular file");
}
if (fileStat.size > this.importMaxBytes) {
throw new Error(
`render payload file exceeds RESULT_REF_IMPORT_MAX_BYTES (${this.importMaxBytes})`,
);
}
const raw = await readJsonFile<unknown>(resolvedFilePath);
if (raw === null) { if (raw === null) {
throw new Error(`render payload file not found: ${filePath}`); throw new Error(`render payload file not found: ${filePath}`);
} }
@@ -78,13 +95,15 @@ export class ResultReferenceResolver {
throw new Error("render payload file does not contain a valid junction render payload"); throw new Error("render payload file does not contain a valid junction render payload");
} }
return this.register({ const record = await this.register({
...input, ...input,
data: payload, data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
schemaVersion: 1, schemaVersion: 1,
source: RESULT_REFERENCE_SOURCE.agentGenerated, source: RESULT_REFERENCE_SOURCE.agentGenerated,
}); });
await removeFileIfExists(resolvedFilePath);
return record;
} }
async getFullAuthorized( async getFullAuthorized(
@@ -167,6 +186,25 @@ export const extractRenderJunctionPayload = (
}; };
}; };
const resolvePathInsideRoot = async (filePath: string, rootPath: string) => {
if (!isAbsolute(filePath)) {
throw new Error("render payload file_path must be absolute");
}
const [resolvedFilePath, resolvedRootPath] = await Promise.all([
realpath(filePath),
realpath(rootPath),
]);
const relativePath = relative(resolvedRootPath, resolvedFilePath);
if (
relativePath === ".." ||
relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
isAbsolute(relativePath)
) {
throw new Error("render payload file must be inside RESULT_REF_IMPORT_DIR");
}
return resolvedFilePath;
};
const normalizeDataForKind = ( const normalizeDataForKind = (
kind: ResultReferenceKind, kind: ResultReferenceKind,
data: unknown, data: unknown,
+7 -1
View File
@@ -65,7 +65,13 @@ const payloadSchema = z.object({
model: z.string().refine(isSupportedModel, { model: z.string().refine(isSupportedModel, {
message: "unsupported model", message: "unsupported model",
}).optional(), }).optional(),
approval_mode: z.enum(["request", "always"]).optional().default("request"), approval_mode: z
.enum(["request", "auto", "always"])
.optional()
.default("request")
.describe(
"request forwards approval prompts; auto approves only low-risk allowlisted tools; always approves every prompt not explicitly denied by OpenCode",
),
}); });
const createSessionPayloadSchema = z.object({ const createSessionPayloadSchema = z.object({
+57
View File
@@ -0,0 +1,57 @@
export type ApprovalMode = "request" | "auto" | "always";
const lowRiskToolPermissions = new Set([
"apply_layer_style",
"geocode",
"locate_features",
"render_junctions",
"show_chart",
"tjwater_server_query",
"view_history",
"view_scada",
"web_search",
"zoom_to_map",
]);
const normalizePermission = (permission: string) => permission.trim().toLowerCase();
export const canAutoApprovePermission = (permission: string): boolean => {
const normalized = normalizePermission(permission);
if (lowRiskToolPermissions.has(normalized)) {
return true;
}
if (normalized.startsWith("tjwater_")) {
return lowRiskToolPermissions.has(normalized.slice("tjwater_".length));
}
return false;
};
export const resolvePermissionApproval = (
approvalMode: ApprovalMode,
permission: string,
) => {
if (approvalMode === "always") {
return {
autoApprove: true,
title: "已按始终允许模式放行",
detail:
"当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。",
} as const;
}
if (approvalMode === "auto" && canAutoApprovePermission(permission)) {
return {
autoApprove: true,
title: "已自动批准低风险权限",
detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。",
} as const;
}
return {
autoApprove: false,
title: "等待权限确认",
detail: undefined,
} as const;
};
+25 -19
View File
@@ -46,6 +46,10 @@ import {
type TodoItemPayload, type TodoItemPayload,
type TodoUpdatePayload, type TodoUpdatePayload,
} from "./chatStreamEvents.js"; } from "./chatStreamEvents.js";
import {
resolvePermissionApproval,
type ApprovalMode,
} from "./chatPermissionPolicy.js";
export { export {
collectTextContent, collectTextContent,
@@ -55,7 +59,7 @@ export {
type TodoUpdatePayload, type TodoUpdatePayload,
} from "./chatStreamEvents.js"; } from "./chatStreamEvents.js";
export type ApprovalMode = "request" | "always"; export type { ApprovalMode } from "./chatPermissionPolicy.js";
type StreamPromptOptions = { type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter; runtime: OpencodeRuntimeAdapter;
@@ -372,6 +376,10 @@ export const streamPromptResponse = async ({
if (isPermissionAskedEvent(event)) { if (isPermissionAskedEvent(event)) {
sawResponseActivity = true; sawResponseActivity = true;
const permissionApproval = resolvePermissionApproval(
approvalMode,
event.properties.permission,
);
logDevelopmentDebug("permission request received", { logDevelopmentDebug("permission request received", {
...debugContext, ...debugContext,
requestId: event.properties.id, requestId: event.properties.id,
@@ -382,23 +390,20 @@ export const streamPromptResponse = async ({
emitProgress({ emitProgress({
id: `permission-${event.properties.id}`, id: `permission-${event.properties.id}`,
phase: "permission", phase: "permission",
status: approvalMode === "always" ? "completed" : "running", status: permissionApproval.autoApprove ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认", title: permissionApproval.title,
detail: detail: permissionApproval.detail ?? buildPermissionDetail(event),
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionDetail(event),
}); });
if (approvalMode === "always") { if (permissionApproval.autoApprove) {
await runtime.replyPermission({ await runtime.replyPermission({
requestId: event.properties.id, requestId: event.properties.id,
sessionId, sessionId,
reply: "always", reply: "once",
}); });
write("permission_response", { write("permission_response", {
session_id: clientSessionId, session_id: clientSessionId,
request_id: event.properties.id, request_id: event.properties.id,
reply: "always" satisfies PermissionReply, reply: "once" satisfies PermissionReply,
}); });
continue; continue;
} }
@@ -417,6 +422,10 @@ export const streamPromptResponse = async ({
if (isPermissionV2AskedEvent(event)) { if (isPermissionV2AskedEvent(event)) {
sawResponseActivity = true; sawResponseActivity = true;
const permissionApproval = resolvePermissionApproval(
approvalMode,
event.properties.action,
);
logDevelopmentDebug("permission v2 request received", { logDevelopmentDebug("permission v2 request received", {
...debugContext, ...debugContext,
requestId: event.properties.id, requestId: event.properties.id,
@@ -427,23 +436,20 @@ export const streamPromptResponse = async ({
emitProgress({ emitProgress({
id: `permission-${event.properties.id}`, id: `permission-${event.properties.id}`,
phase: "permission", phase: "permission",
status: approvalMode === "always" ? "completed" : "running", status: permissionApproval.autoApprove ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认", title: permissionApproval.title,
detail: detail: permissionApproval.detail ?? buildPermissionV2Detail(event),
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionV2Detail(event),
}); });
if (approvalMode === "always") { if (permissionApproval.autoApprove) {
await runtime.replyPermission({ await runtime.replyPermission({
requestId: event.properties.id, requestId: event.properties.id,
sessionId, sessionId,
reply: "always", reply: "once",
}); });
write("permission_response", { write("permission_response", {
session_id: clientSessionId, session_id: clientSessionId,
request_id: event.properties.id, request_id: event.properties.id,
reply: "always" satisfies PermissionReply, reply: "once" satisfies PermissionReply,
}); });
continue; continue;
} }
+1
View File
@@ -393,6 +393,7 @@ export class OpencodeRuntimeAdapter {
config.AGENT_INTERNAL_TOKEN ?? config.AGENT_INTERNAL_TOKEN ??
process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
""; "";
process.env.RESULT_REF_IMPORT_DIR = config.RESULT_REF_IMPORT_DIR;
logger.info( logger.info(
{ {
+7 -1
View File
@@ -37,6 +37,7 @@ import {
markRuntimeSessionAuthExpired, markRuntimeSessionAuthExpired,
type RuntimeSessionContext, type RuntimeSessionContext,
} from "./runtime/sessionContext.js"; } from "./runtime/sessionContext.js";
import { ensureDirectory } from "./utils/fileStore.js";
import { SkillStore } from "./skills/store.js"; import { SkillStore } from "./skills/store.js";
const app = express(); const app = express();
@@ -55,7 +56,11 @@ const learningOrchestrator = new LearningOrchestrator(
skillStore, skillStore,
); );
const resultReferenceStore = new ResultReferenceStore(); const resultReferenceStore = new ResultReferenceStore();
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); const resultReferenceResolver = new ResultReferenceResolver(
resultReferenceStore,
config.RESULT_REF_IMPORT_DIR,
config.RESULT_REF_IMPORT_MAX_BYTES,
);
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
const credentialRefreshCoordinator = new CredentialRefreshCoordinator(); const credentialRefreshCoordinator = new CredentialRefreshCoordinator();
@@ -660,6 +665,7 @@ const bootstrap = async () => {
learningOrchestrator.initialize(), learningOrchestrator.initialize(),
memoryStore.initialize(), memoryStore.initialize(),
resultReferenceStore.initialize(), resultReferenceStore.initialize(),
ensureDirectory(config.RESULT_REF_IMPORT_DIR),
sessionTranscriptStore.initialize(), sessionTranscriptStore.initialize(),
]); ]);
}; };
+28
View File
@@ -61,6 +61,34 @@ describe("Agent REST OpenAPI", () => {
expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined(); 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", () => { test("matches the public session runtime response shapes", () => {
const document = generateAgentOpenApi(); const document = generateAgentOpenApi();
const schemas = document.components?.schemas ?? {}; const schemas = document.components?.schemas ?? {};
+49 -2
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"; 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -18,7 +18,7 @@ describe("ResultReferenceResolver", () => {
beforeEach(async () => { beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-")); tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
store = new ResultReferenceStore(tempDir, 60_000); store = new ResultReferenceStore(tempDir, 60_000);
resolver = new ResultReferenceResolver(store); resolver = new ResultReferenceResolver(store, tempDir, 1024 * 1024);
await store.initialize(); await store.initialize();
}); });
@@ -193,6 +193,53 @@ describe("ResultReferenceResolver", () => {
"DMA-2": "#00ff00", "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");
}); });
}); });
+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>); } 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 replies: Array<Record<string, unknown>> = [];
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () =>
@@ -71,10 +71,10 @@ describe("streamPromptResponse", () => {
properties: { properties: {
id: "perm-1", id: "perm-1",
sessionID: "runtime-session-1", sessionID: "runtime-session-1",
permission: "bash", permission: "tjwater_tjwater_server_query",
patterns: ["npm test"], patterns: ["*"],
metadata: { command: "npm test" }, metadata: {},
always: ["npm test"], always: ["*"],
}, },
}, },
{ {
@@ -97,7 +97,7 @@ describe("streamPromptResponse", () => {
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
clientSessionId: "client-session-1", clientSessionId: "client-session-1",
message: "run tests", message: "run tests",
approvalMode: "always", approvalMode: "auto",
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
@@ -105,17 +105,100 @@ describe("streamPromptResponse", () => {
{ {
requestId: "perm-1", requestId: "perm-1",
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
reply: "always", reply: "once",
}, },
]); ]);
expect(events.some((item) => item.event === "permission_request")).toBe(false); expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({ expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1", session_id: "client-session-1",
request_id: "perm-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 () => { it("forwards opencode v2 permission requests as SSE payloads", async () => {
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () =>
+27
View File
@@ -129,4 +129,31 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
"session.delete:warmup-session", "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: [["继续"]] },
]);
});
}); });
+29
View File
@@ -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");
});
});