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
+8
View File
@@ -103,6 +103,14 @@ const envSchema = z
LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
// result_ref 持久化存储目录。
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_TTL_HOURS: z.coerce.number().int().positive().default(168),
// 定时清理过期 result_ref 的扫描周期(毫秒)。
+6 -1
View File
@@ -214,7 +214,12 @@ register("/api/v1/agent/sessions/{session_id}/runs", "post", {
schema: z.object({
message: z.string().min(1).max(10000),
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 {
type ResultReferenceKind,
type ResultReferenceRecord,
@@ -33,7 +36,11 @@ export type RenderJunctionPayload = {
};
export class ResultReferenceResolver {
constructor(private readonly store: ResultReferenceStore) {}
constructor(
private readonly store: ResultReferenceStore,
private readonly importRoot: string,
private readonly importMaxBytes: number,
) {}
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
async register(input: RegisterResultReferenceInput) {
@@ -63,7 +70,17 @@ export class ResultReferenceResolver {
filePath: string,
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) {
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");
}
return this.register({
const record = await this.register({
...input,
data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
schemaVersion: 1,
source: RESULT_REFERENCE_SOURCE.agentGenerated,
});
await removeFileIfExists(resolvedFilePath);
return record;
}
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 = (
kind: ResultReferenceKind,
data: unknown,
+7 -1
View File
@@ -65,7 +65,13 @@ const payloadSchema = z.object({
model: z.string().refine(isSupportedModel, {
message: "unsupported model",
}).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({
+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 TodoUpdatePayload,
} from "./chatStreamEvents.js";
import {
resolvePermissionApproval,
type ApprovalMode,
} from "./chatPermissionPolicy.js";
export {
collectTextContent,
@@ -55,7 +59,7 @@ export {
type TodoUpdatePayload,
} from "./chatStreamEvents.js";
export type ApprovalMode = "request" | "always";
export type { ApprovalMode } from "./chatPermissionPolicy.js";
type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter;
@@ -372,6 +376,10 @@ export const streamPromptResponse = async ({
if (isPermissionAskedEvent(event)) {
sawResponseActivity = true;
const permissionApproval = resolvePermissionApproval(
approvalMode,
event.properties.permission,
);
logDevelopmentDebug("permission request received", {
...debugContext,
requestId: event.properties.id,
@@ -382,23 +390,20 @@ export const streamPromptResponse = async ({
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionDetail(event),
status: permissionApproval.autoApprove ? "completed" : "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionDetail(event),
});
if (approvalMode === "always") {
if (permissionApproval.autoApprove) {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "always",
reply: "once",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "always" satisfies PermissionReply,
reply: "once" satisfies PermissionReply,
});
continue;
}
@@ -417,6 +422,10 @@ export const streamPromptResponse = async ({
if (isPermissionV2AskedEvent(event)) {
sawResponseActivity = true;
const permissionApproval = resolvePermissionApproval(
approvalMode,
event.properties.action,
);
logDevelopmentDebug("permission v2 request received", {
...debugContext,
requestId: event.properties.id,
@@ -427,23 +436,20 @@ export const streamPromptResponse = async ({
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionV2Detail(event),
status: permissionApproval.autoApprove ? "completed" : "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionV2Detail(event),
});
if (approvalMode === "always") {
if (permissionApproval.autoApprove) {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "always",
reply: "once",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "always" satisfies PermissionReply,
reply: "once" satisfies PermissionReply,
});
continue;
}
+1
View File
@@ -393,6 +393,7 @@ export class OpencodeRuntimeAdapter {
config.AGENT_INTERNAL_TOKEN ??
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
"";
process.env.RESULT_REF_IMPORT_DIR = config.RESULT_REF_IMPORT_DIR;
logger.info(
{
+7 -1
View File
@@ -37,6 +37,7 @@ import {
markRuntimeSessionAuthExpired,
type RuntimeSessionContext,
} from "./runtime/sessionContext.js";
import { ensureDirectory } from "./utils/fileStore.js";
import { SkillStore } from "./skills/store.js";
const app = express();
@@ -55,7 +56,11 @@ const learningOrchestrator = new LearningOrchestrator(
skillStore,
);
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 credentialRefreshCoordinator = new CredentialRefreshCoordinator();
@@ -660,6 +665,7 @@ const bootstrap = async () => {
learningOrchestrator.initialize(),
memoryStore.initialize(),
resultReferenceStore.initialize(),
ensureDirectory(config.RESULT_REF_IMPORT_DIR),
sessionTranscriptStore.initialize(),
]);
};