fix(agent): isolate conversation workspaces
Generic Container CI/CD / test-build-publish (push) Successful in 2m2s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m2s

This commit is contained in:
2026-08-25 13:18:39 +08:00
parent 004c9bb72d
commit ce04704af2
18 changed files with 621 additions and 48 deletions
+7 -2
View File
@@ -155,7 +155,9 @@ export const buildChatRouter = (
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim();
const sessionId = requestedSessionId || (await runtime.createSession()).id;
const sessionId =
requestedSessionId ||
(await runtime.createSession(undefined, { conversationWorkspace: true })).id;
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
@@ -451,7 +453,9 @@ export const buildChatRouter = (
res.status(404).json({ message: "source session not found" });
return;
}
const forkSession = await runtime.createSession();
const forkSession = await runtime.createSession(undefined, {
conversationWorkspace: true,
});
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
@@ -874,6 +878,7 @@ export const buildChatRouter = (
traceId: requestContext.traceId,
projectId: requestContext.projectId,
signal: abortController.signal,
workspaceRoot: binding.workspaceDirectory,
write: (event, data) => {
publish(event, data);
},
+148
View File
@@ -62,9 +62,32 @@ export const resolvePermissionApproval = (
permission: string,
context: PermissionApprovalContext = {},
) => {
if (isDirectRecursiveForceRemove(permission, context)) {
return {
autoApprove: false,
autoReject: true,
title: "已拒绝递归强制删除",
detail: "当前安全策略禁止直接执行带 recursive 和 force 参数的 rm 命令。",
} as const;
}
if (
approvalMode === "always" &&
normalizePermission(permission) === "bash" &&
containsPotentialRemoveCommand(context)
) {
return {
autoApprove: false,
autoReject: false,
title: "等待删除命令确认",
detail: "删除命令不会由始终允许模式代为批准,请确认本次具体操作。",
} as const;
}
if (approvalMode === "always") {
return {
autoApprove: true,
autoReject: false,
title: "已按始终允许模式放行",
detail:
"当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。",
@@ -74,6 +97,7 @@ export const resolvePermissionApproval = (
if (approvalMode === "auto" && canAutoApprovePermission(permission, context)) {
return {
autoApprove: true,
autoReject: false,
title: "已自动批准低风险权限",
detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。",
} as const;
@@ -81,11 +105,135 @@ export const resolvePermissionApproval = (
return {
autoApprove: false,
autoReject: false,
title: "等待权限确认",
detail: undefined,
} as const;
};
const isDirectRecursiveForceRemove = (
permission: string,
context: PermissionApprovalContext,
): boolean => {
if (normalizePermission(permission) !== "bash") {
return false;
}
const command =
typeof context.metadata?.command === "string"
? context.metadata.command
: context.patterns?.join("\n");
if (!command) {
return false;
}
return splitShellCommandSegments(command).some((segment) => {
const words = tokenizeShellSegment(segment);
let commandIndex = 0;
while (commandIndex < words.length) {
const word = words[commandIndex]!;
const executable = word.split("/").at(-1)?.toLowerCase();
if (word === "!" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
commandIndex += 1;
continue;
}
if (executable === "command") {
commandIndex += 1;
while (words[commandIndex]?.startsWith("-") && words[commandIndex] !== "--") {
commandIndex += 1;
}
if (words[commandIndex] === "--") commandIndex += 1;
continue;
}
if (executable === "env") {
commandIndex += 1;
while (commandIndex < words.length) {
const envWord = words[commandIndex]!;
if (envWord === "--") {
commandIndex += 1;
break;
}
if (envWord === "-u" || envWord === "--unset") {
commandIndex += 2;
continue;
}
if (
envWord.startsWith("-") ||
/^[A-Za-z_][A-Za-z0-9_]*=/.test(envWord)
) {
commandIndex += 1;
continue;
}
break;
}
continue;
}
if (executable === "sudo" || executable === "doas") {
commandIndex += 1;
while (words[commandIndex]?.startsWith("-")) {
commandIndex += 1;
}
continue;
}
if (executable === "busybox" && words[commandIndex + 1] === "rm") {
commandIndex += 1;
}
break;
}
const executable = words[commandIndex]?.split("/").at(-1)?.toLowerCase();
if (executable !== "rm") {
return false;
}
let recursive = false;
let force = false;
for (const word of words.slice(commandIndex + 1)) {
if (word === "--") {
break;
}
if (word === "--recursive") {
recursive = true;
} else if (word === "--force") {
force = true;
} else if (/^-[^-]/.test(word)) {
recursive ||= /[rR]/.test(word.slice(1));
force ||= word.slice(1).includes("f");
}
}
return recursive && force;
});
};
const containsPotentialRemoveCommand = (
context: PermissionApprovalContext,
): boolean => {
const command =
typeof context.metadata?.command === "string"
? context.metadata.command
: context.patterns?.join("\n");
if (!command) {
return false;
}
const normalized = command
.replace(/\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu, "")
.replace(/["'\\]/gu, "");
return /(^|[^A-Za-z0-9_])(?:[^\s/]+\/)*rm(?=$|[^A-Za-z0-9_])/iu.test(normalized);
};
const splitShellCommandSegments = (command: string): string[] =>
command.split(/&&|\|\||[;|()\n]/u);
const tokenizeShellSegment = (segment: string): string[] =>
(segment.match(/(?:[^\s"'\\]+|"(?:\\.|[^"])*"|'[^']*')+/gu) ?? []).map(
(word) => {
const quoted = word.match(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u);
return (quoted ? (quoted[1] ?? quoted[2] ?? "") : word).replace(
/(["'])|\\(.)/gu,
"$2",
);
},
);
const isSafeWorkspaceSearch = (
permission: "glob" | "grep",
context: PermissionApprovalContext,
+22 -10
View File
@@ -71,6 +71,7 @@ type StreamPromptOptions = {
traceId?: string;
projectId?: string;
signal?: AbortSignal;
workspaceRoot?: string;
write: (event: string, data: Record<string, unknown>) => void;
};
@@ -157,6 +158,7 @@ export const streamPromptResponse = async ({
traceId,
projectId,
signal,
workspaceRoot,
write,
}: StreamPromptOptions): Promise<{
aborted: boolean;
@@ -397,7 +399,7 @@ export const streamPromptResponse = async ({
{
metadata: event.properties.metadata,
patterns: event.properties.patterns,
workspaceRoot: process.cwd(),
workspaceRoot: workspaceRoot ?? process.cwd(),
},
);
logDevelopmentDebug("permission request received", {
@@ -410,20 +412,25 @@ export const streamPromptResponse = async ({
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: permissionApproval.autoApprove ? "completed" : "running",
status: permissionApproval.autoReject
? "error"
: permissionApproval.autoApprove
? "completed"
: "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionDetail(event),
});
if (permissionApproval.autoApprove) {
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
const reply = permissionApproval.autoReject ? "reject" : "once";
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "once",
reply,
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "once" satisfies PermissionReply,
reply: reply satisfies PermissionReply,
});
continue;
}
@@ -448,7 +455,7 @@ export const streamPromptResponse = async ({
{
metadata: event.properties.metadata,
patterns: event.properties.resources,
workspaceRoot: process.cwd(),
workspaceRoot: workspaceRoot ?? process.cwd(),
},
);
logDevelopmentDebug("permission v2 request received", {
@@ -461,20 +468,25 @@ export const streamPromptResponse = async ({
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: permissionApproval.autoApprove ? "completed" : "running",
status: permissionApproval.autoReject
? "error"
: permissionApproval.autoApprove
? "completed"
: "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionV2Detail(event),
});
if (permissionApproval.autoApprove) {
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
const reply = permissionApproval.autoReject ? "reject" : "once";
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "once",
reply,
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "once" satisfies PermissionReply,
reply: reply satisfies PermissionReply,
});
continue;
}