Compare commits
4
Commits
v2026.09.25.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4744f1f20a | ||
|
|
54d8feca14 | ||
|
|
7f4918a1de | ||
|
|
0eab59c52e |
+3
-1
@@ -19,6 +19,7 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
|
|||||||
curl \
|
curl \
|
||||||
jq \
|
jq \
|
||||||
libseccomp2 \
|
libseccomp2 \
|
||||||
|
ripgrep \
|
||||||
unzip \
|
unzip \
|
||||||
python3 \
|
python3 \
|
||||||
python3-venv && \
|
python3-venv && \
|
||||||
@@ -63,7 +64,7 @@ COPY patches/opencode-1.18.13-message-phase.patch /tmp/opencode-message-phase.pa
|
|||||||
|
|
||||||
RUN git apply --check /tmp/opencode-message-phase.patch && \
|
RUN git apply --check /tmp/opencode-message-phase.patch && \
|
||||||
git apply /tmp/opencode-message-phase.patch && \
|
git apply /tmp/opencode-message-phase.patch && \
|
||||||
bun install --frozen-lockfile --ignore-scripts && \
|
bun install --frozen-lockfile --ignore-scripts --filter ./packages/opencode --filter @opencode-ai/llm && \
|
||||||
bun test --cwd packages/llm test/provider/openai-responses.test.ts && \
|
bun test --cwd packages/llm test/provider/openai-responses.test.ts && \
|
||||||
OPENCODE_VERSION="$OPENCODE_PATCH_VERSION" bun run --cwd packages/opencode build --single --skip-install --skip-embed-web-ui && \
|
OPENCODE_VERSION="$OPENCODE_PATCH_VERSION" bun run --cwd packages/opencode build --single --skip-install --skip-embed-web-ui && \
|
||||||
case "$(uname -m)" in \
|
case "$(uname -m)" in \
|
||||||
@@ -96,6 +97,7 @@ RUN bun run check
|
|||||||
|
|
||||||
FROM build AS test
|
FROM build AS test
|
||||||
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
|
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
|
||||||
|
RUN command -v rg && rg --version
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends nodejs && \
|
RUN apt-get update && apt-get install -y --no-install-recommends nodejs && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
COPY contracts ./contracts
|
COPY contracts ./contracts
|
||||||
|
|||||||
@@ -1014,7 +1014,7 @@
|
|||||||
"auto",
|
"auto",
|
||||||
"always"
|
"always"
|
||||||
],
|
],
|
||||||
"description": "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode."
|
"description": "request forwards approval prompts; auto approves only the low-risk allowlist and forwards other requests; always approves routine prompts but forwards sensitive paths and force removal for user approval."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"agent": {
|
"agent": {
|
||||||
"file": "agent-v1.openapi.json",
|
"file": "agent-v1.openapi.json",
|
||||||
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
|
"sha256": "dbfaec4b72eb30592523f7c5a21097d4cfb2e3beb370540c6da24b3d4aa5b366"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-28
@@ -14,42 +14,45 @@
|
|||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"*": "ask",
|
"*": "ask",
|
||||||
"external_directory": "deny",
|
"external_directory": {
|
||||||
|
"*": "ask",
|
||||||
|
"/app/.opencode/skills/*": "allow"
|
||||||
|
},
|
||||||
"read": {
|
"read": {
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
".env": "deny",
|
".env": "ask",
|
||||||
".env.*": "deny",
|
".env.*": "ask",
|
||||||
"*.env": "deny",
|
"*.env": "ask",
|
||||||
"**/.env": "deny",
|
"**/.env": "ask",
|
||||||
"**/.env.*": "deny",
|
"**/.env.*": "ask",
|
||||||
"**/*.env": "deny",
|
"**/*.env": "ask",
|
||||||
"data/**": "deny",
|
"data/**": "ask",
|
||||||
"**/data/**": "deny",
|
"**/data/**": "ask",
|
||||||
"logs/**": "deny",
|
"logs/**": "ask",
|
||||||
"**/logs/**": "deny"
|
"**/logs/**": "ask"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"*": "ask",
|
"*": "ask",
|
||||||
".env": "deny",
|
".env": "ask",
|
||||||
".env.*": "deny",
|
".env.*": "ask",
|
||||||
"*.env": "deny",
|
"*.env": "ask",
|
||||||
"**/.env": "deny",
|
"**/.env": "ask",
|
||||||
"**/.env.*": "deny",
|
"**/.env.*": "ask",
|
||||||
"**/*.env": "deny",
|
"**/*.env": "ask",
|
||||||
"data/**": "deny",
|
"data/**": "ask",
|
||||||
"**/data/**": "deny",
|
"**/data/**": "ask",
|
||||||
"logs/**": "deny",
|
"logs/**": "ask",
|
||||||
"**/logs/**": "deny"
|
"**/logs/**": "ask"
|
||||||
},
|
},
|
||||||
"bash": {
|
"bash": {
|
||||||
"*": "ask",
|
"*": "ask",
|
||||||
"rm *": "ask",
|
"rm *": "ask",
|
||||||
"rm -rf *": "deny",
|
"rm -rf *": "ask",
|
||||||
"rm -fr *": "deny",
|
"rm -fr *": "ask",
|
||||||
"rm -r -f *": "deny",
|
"rm -r -f *": "ask",
|
||||||
"rm -f -r *": "deny",
|
"rm -f -r *": "ask",
|
||||||
"rm --recursive --force *": "deny",
|
"rm --recursive --force *": "ask",
|
||||||
"rm --force --recursive *": "deny",
|
"rm --force --recursive *": "ask",
|
||||||
"rmdir *": "ask",
|
"rmdir *": "ask",
|
||||||
"mv *": "ask",
|
"mv *": "ask",
|
||||||
"chmod *": "ask",
|
"chmod *": "ask",
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
"sudo *": "ask",
|
"sudo *": "ask",
|
||||||
"curl *": "ask",
|
"curl *": "ask",
|
||||||
"wget *": "ask",
|
"wget *": "ask",
|
||||||
"*.env*": "deny"
|
"*.env*": "ask"
|
||||||
},
|
},
|
||||||
"question": "allow",
|
"question": "allow",
|
||||||
"activity_update": "allow",
|
"activity_update": "allow",
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ register("/api/v1/agent/sessions/{session_id}/runs", "post", {
|
|||||||
.enum(["request", "auto", "always"])
|
.enum(["request", "auto", "always"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode.",
|
"request forwards approval prompts; auto approves only the low-risk allowlist and forwards other requests; always approves routine prompts but forwards sensitive paths and force removal for user approval.",
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ const payloadSchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.default("request")
|
.default("request")
|
||||||
.describe(
|
.describe(
|
||||||
"request forwards approval prompts; auto approves only low-risk allowlisted tools; always approves every prompt not explicitly denied by OpenCode",
|
"request forwards approval prompts; auto approves only low-risk allowlisted tools and forwards other requests; always approves routine prompts but forwards sensitive paths and force removal for user approval",
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Part } from "@opencode-ai/sdk/v2";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getToolLabel,
|
getToolLabel,
|
||||||
|
initialActivity,
|
||||||
normalizeToolStatus,
|
normalizeToolStatus,
|
||||||
type ActivityActionPayload,
|
type ActivityActionPayload,
|
||||||
type ActivityPayload,
|
type ActivityPayload,
|
||||||
@@ -12,6 +13,51 @@ import {
|
|||||||
|
|
||||||
type ToolPart = Extract<Part, { type: "tool" }>;
|
type ToolPart = Extract<Part, { type: "tool" }>;
|
||||||
type ActivityContext = Pick<ActivityPayload, "id" | "title" | "reason">;
|
type ActivityContext = Pick<ActivityPayload, "id" | "title" | "reason">;
|
||||||
|
type AutomaticStageKind = "reference" | "data" | "analysis" | "visualization" | "other";
|
||||||
|
|
||||||
|
const automaticStages: Record<AutomaticStageKind, Pick<ActivityPayload, "title" | "reason">> = {
|
||||||
|
reference: {
|
||||||
|
title: "查找所需资料",
|
||||||
|
reason: "正在检索完成本次请求所需的工作流和参考信息。",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: "获取业务数据",
|
||||||
|
reason: "正在查询本次分析所需的数据和业务能力。",
|
||||||
|
},
|
||||||
|
analysis: {
|
||||||
|
title: "处理分析数据",
|
||||||
|
reason: "正在对已获取的信息进行计算和核查。",
|
||||||
|
},
|
||||||
|
visualization: {
|
||||||
|
title: "更新地图与图表",
|
||||||
|
reason: "正在将分析内容呈现在地图或图表中。",
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
title: "执行分析操作",
|
||||||
|
reason: "正在处理完成本次请求所需的操作。",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const referenceTools = new Set(["skill", "read", "glob", "grep", "session_search"]);
|
||||||
|
const dataTools = new Set(["tjwater_cli", "web_search", "geocode"]);
|
||||||
|
const visualizationTools = new Set([
|
||||||
|
"store_render_ref",
|
||||||
|
"locate_features",
|
||||||
|
"zoom_to_map",
|
||||||
|
"view_scada",
|
||||||
|
"show_chart",
|
||||||
|
"render_junctions",
|
||||||
|
"view_history",
|
||||||
|
"apply_layer_style",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getAutomaticStageKind = (tool: string): AutomaticStageKind => {
|
||||||
|
if (referenceTools.has(tool)) return "reference";
|
||||||
|
if (dataTools.has(tool)) return "data";
|
||||||
|
if (tool === "bash") return "analysis";
|
||||||
|
if (visualizationTools.has(tool)) return "visualization";
|
||||||
|
return "other";
|
||||||
|
};
|
||||||
|
|
||||||
const getActionTarget = (params: Record<string, unknown>) => {
|
const getActionTarget = (params: Record<string, unknown>) => {
|
||||||
for (const key of ["command", "file_path", "filePath", "path", "query", "keyword"]) {
|
for (const key of ["command", "file_path", "filePath", "path", "query", "keyword"]) {
|
||||||
@@ -33,6 +79,9 @@ export const createActivityTracker = ({
|
|||||||
const activities = new Map<string, ActivityPayload>();
|
const activities = new Map<string, ActivityPayload>();
|
||||||
const actionActivityIds = new Map<string, string>();
|
const actionActivityIds = new Map<string, string>();
|
||||||
let currentActivityId: string | null = null;
|
let currentActivityId: string | null = null;
|
||||||
|
let currentStageOrigin: "startup" | "explicit" | "automatic" = "startup";
|
||||||
|
let currentAutomaticKind: AutomaticStageKind | null = null;
|
||||||
|
let automaticStageCount = 0;
|
||||||
|
|
||||||
const emit = (activity: ActivityPayload, todos?: TodoItemPayload[]) => {
|
const emit = (activity: ActivityPayload, todos?: TodoItemPayload[]) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -88,11 +137,13 @@ export const createActivityTracker = ({
|
|||||||
emit(nextActivity);
|
emit(nextActivity);
|
||||||
};
|
};
|
||||||
|
|
||||||
const start = (
|
const startStage = (
|
||||||
id: string,
|
id: string,
|
||||||
title: string,
|
title: string,
|
||||||
reason: string,
|
reason: string,
|
||||||
todos?: TodoItemPayload[],
|
todos: TodoItemPayload[] | undefined,
|
||||||
|
origin: typeof currentStageOrigin,
|
||||||
|
automaticKind: AutomaticStageKind | null = null,
|
||||||
) => {
|
) => {
|
||||||
finalize("completed");
|
finalize("completed");
|
||||||
const activity: ActivityPayload = {
|
const activity: ActivityPayload = {
|
||||||
@@ -106,17 +157,47 @@ export const createActivityTracker = ({
|
|||||||
};
|
};
|
||||||
activities.set(id, activity);
|
activities.set(id, activity);
|
||||||
currentActivityId = id;
|
currentActivityId = id;
|
||||||
|
currentStageOrigin = origin;
|
||||||
|
currentAutomaticKind = automaticKind;
|
||||||
emit(activity, todos);
|
emit(activity, todos);
|
||||||
return activity;
|
return activity;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const start = (
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
reason: string,
|
||||||
|
todos?: TodoItemPayload[],
|
||||||
|
) => startStage(
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
reason,
|
||||||
|
todos,
|
||||||
|
id === initialActivity.id ? "startup" : "explicit",
|
||||||
|
);
|
||||||
|
|
||||||
const ensure = (tool: string) => {
|
const ensure = (tool: string) => {
|
||||||
const activity = current();
|
const activity = current();
|
||||||
if (activity?.status === "running") return activity;
|
if (activity?.status === "running" && currentStageOrigin === "explicit") {
|
||||||
return start(
|
return activity;
|
||||||
`activity-fallback-${Date.now().toString(36)}`,
|
}
|
||||||
"执行分析操作",
|
const kind = getAutomaticStageKind(tool);
|
||||||
`为完成当前请求,需要使用${getToolLabel(tool)}处理相关信息。`,
|
if (
|
||||||
|
activity?.status === "running" &&
|
||||||
|
currentStageOrigin === "automatic" &&
|
||||||
|
(currentAutomaticKind === kind ||
|
||||||
|
activity.actions.some((action) => action.status === "running"))
|
||||||
|
) {
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
const stage = automaticStages[kind];
|
||||||
|
return startStage(
|
||||||
|
`activity-auto-${++automaticStageCount}`,
|
||||||
|
stage.title,
|
||||||
|
stage.reason,
|
||||||
|
undefined,
|
||||||
|
"automatic",
|
||||||
|
kind,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const canAutoApprovePermission = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (normalized === "bash") {
|
if (normalized === "bash") {
|
||||||
return isConversationWorkspace(context.workspaceRoot);
|
return isConversationWorkspace(context.workspaceRoot) && !isSensitiveBashCommand(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized === "read" || normalized === "edit" || normalized === "write") {
|
if (normalized === "read" || normalized === "edit" || normalized === "write") {
|
||||||
@@ -70,12 +70,20 @@ export const resolvePermissionApproval = (
|
|||||||
permission: string,
|
permission: string,
|
||||||
context: PermissionApprovalContext = {},
|
context: PermissionApprovalContext = {},
|
||||||
) => {
|
) => {
|
||||||
if (isDirectRecursiveForceRemove(permission, context)) {
|
const normalized = normalizePermission(permission);
|
||||||
|
if (
|
||||||
|
isSensitiveBashCommand(context, normalized) ||
|
||||||
|
(approvalMode === "always" &&
|
||||||
|
(normalized === "external_directory" ||
|
||||||
|
normalized === "read" ||
|
||||||
|
((normalized === "edit" || normalized === "write") &&
|
||||||
|
!isSafeConversationFileAccess(context))))
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
autoApprove: false,
|
autoApprove: false,
|
||||||
autoReject: true,
|
autoReject: false,
|
||||||
title: "已拒绝递归强制删除",
|
title: "等待权限确认",
|
||||||
detail: "当前安全策略禁止直接执行带 recursive 和 force 参数的 rm 命令。",
|
detail: undefined,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +93,7 @@ export const resolvePermissionApproval = (
|
|||||||
autoReject: false,
|
autoReject: false,
|
||||||
title: "已按始终允许模式放行",
|
title: "已按始终允许模式放行",
|
||||||
detail:
|
detail:
|
||||||
"当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。",
|
"当前会话处于始终允许模式,已放行本次常规权限请求。",
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +114,21 @@ export const resolvePermissionApproval = (
|
|||||||
} as const;
|
} as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSensitiveBashCommand = (
|
||||||
|
context: PermissionApprovalContext,
|
||||||
|
normalizedPermission = "bash",
|
||||||
|
): boolean => {
|
||||||
|
if (normalizedPermission !== "bash") return false;
|
||||||
|
const command =
|
||||||
|
typeof context.metadata?.command === "string"
|
||||||
|
? context.metadata.command
|
||||||
|
: context.patterns?.join("\n");
|
||||||
|
return Boolean(
|
||||||
|
(command && containsProtectedPath(command, true)) ||
|
||||||
|
isDirectRecursiveForceRemove("bash", context),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const isDirectRecursiveForceRemove = (
|
const isDirectRecursiveForceRemove = (
|
||||||
permission: string,
|
permission: string,
|
||||||
context: PermissionApprovalContext,
|
context: PermissionApprovalContext,
|
||||||
@@ -412,9 +435,16 @@ const isSafeConversationFileAccess = (
|
|||||||
relativeTarget !== ".." &&
|
relativeTarget !== ".." &&
|
||||||
!relativeTarget.startsWith(`..${sep}`) &&
|
!relativeTarget.startsWith(`..${sep}`) &&
|
||||||
!isAbsolute(relativeTarget) &&
|
!isAbsolute(relativeTarget) &&
|
||||||
!containsProtectedPath(relativeTarget, true)
|
!containsSensitiveFileSegment(relativeTarget)
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const containsSensitiveFileSegment = (value: string): boolean =>
|
||||||
|
value
|
||||||
|
.replaceAll("\\", "/")
|
||||||
|
.toLowerCase()
|
||||||
|
.split("/")
|
||||||
|
.some((segment) => segment.includes(".env") || segment === "data" || segment === "logs");
|
||||||
|
|||||||
@@ -1,8 +1,72 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
import { createActivityTracker } from "../../src/routes/chatActivityTracker.js";
|
import { createActivityTracker } from "../../src/routes/chatActivityTracker.js";
|
||||||
|
import { initialActivity } from "../../src/routes/chatStreamEvents.js";
|
||||||
|
|
||||||
describe("createActivityTracker", () => {
|
describe("createActivityTracker", () => {
|
||||||
|
it("moves real tool work out of preparation when the model omits activity updates", () => {
|
||||||
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
const tracker = createActivityTracker({
|
||||||
|
clientSessionId: "client-session-1",
|
||||||
|
write: (event, data) => events.push({ event, data }),
|
||||||
|
});
|
||||||
|
const action = (id: string, tool: string, status: string) =>
|
||||||
|
tracker.upsertAction(
|
||||||
|
{ id, tool, state: { status, input: {} } } as never,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
tracker.start(initialActivity.id, initialActivity.title, initialActivity.reason);
|
||||||
|
action("skill-1", "skill", "completed");
|
||||||
|
action("read-1", "read", "completed");
|
||||||
|
action("data-1", "tjwater_cli", "completed");
|
||||||
|
action("data-1", "tjwater_cli", "completed");
|
||||||
|
action("compute-1", "bash", "completed");
|
||||||
|
action("map-1", "show_chart", "completed");
|
||||||
|
tracker.finalize("completed");
|
||||||
|
|
||||||
|
const started = events
|
||||||
|
.filter(({ data }) =>
|
||||||
|
(data.activity as { actions: unknown[] }).actions.length === 0 &&
|
||||||
|
(data.activity as { status: string }).status === "running",
|
||||||
|
)
|
||||||
|
.map(({ data }) => (data.activity as { title: string }).title);
|
||||||
|
expect(started).toEqual([
|
||||||
|
initialActivity.title,
|
||||||
|
"查找所需资料",
|
||||||
|
"获取业务数据",
|
||||||
|
"处理分析数据",
|
||||||
|
"更新地图与图表",
|
||||||
|
]);
|
||||||
|
expect(tracker.getActionContext("read-1")?.title).toBe("查找所需资料");
|
||||||
|
expect(tracker.getActionContext("data-1")?.title).toBe("获取业务数据");
|
||||||
|
expect(tracker.getActionContext("map-1")?.title).toBe("更新地图与图表");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a model-provided stage across tool categories", () => {
|
||||||
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
const tracker = createActivityTracker({
|
||||||
|
clientSessionId: "client-session-1",
|
||||||
|
write: (event, data) => events.push({ event, data }),
|
||||||
|
});
|
||||||
|
tracker.start(initialActivity.id, initialActivity.title, initialActivity.reason);
|
||||||
|
tracker.start("model-stage", "验证分析结果", "核对本次计算与数据。");
|
||||||
|
for (const [id, tool] of [["tool-1", "tjwater_cli"], ["tool-2", "show_chart"]]) {
|
||||||
|
tracker.upsertAction(
|
||||||
|
{ id, tool, state: { status: "completed", input: {} } } as never,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(tracker.getActionContext("tool-1")?.title).toBe("验证分析结果");
|
||||||
|
expect(tracker.getActionContext("tool-2")?.title).toBe("验证分析结果");
|
||||||
|
expect(
|
||||||
|
events.filter(({ data }) =>
|
||||||
|
(data.activity as { actions: unknown[] }).actions.length === 0 &&
|
||||||
|
(data.activity as { status: string }).status === "running",
|
||||||
|
),
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("groups actions and closes running children with their activity", () => {
|
it("groups actions and closes running children with their activity", () => {
|
||||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
const tracker = createActivityTracker({
|
const tracker = createActivityTracker({
|
||||||
|
|||||||
@@ -124,10 +124,22 @@ describe("permission approval policy", () => {
|
|||||||
expect(resolvePermissionApproval("request", "show_chart").autoApprove).toBe(false);
|
expect(resolvePermissionApproval("request", "show_chart").autoApprove).toBe(false);
|
||||||
expect(resolvePermissionApproval("auto", "show_chart").autoApprove).toBe(true);
|
expect(resolvePermissionApproval("auto", "show_chart").autoApprove).toBe(true);
|
||||||
expect(resolvePermissionApproval("auto", "bash").autoApprove).toBe(false);
|
expect(resolvePermissionApproval("auto", "bash").autoApprove).toBe(false);
|
||||||
|
expect(resolvePermissionApproval("auto", "external_directory")).toMatchObject({
|
||||||
|
autoApprove: false,
|
||||||
|
autoReject: false,
|
||||||
|
});
|
||||||
expect(resolvePermissionApproval("always", "bash")).toMatchObject({
|
expect(resolvePermissionApproval("always", "bash")).toMatchObject({
|
||||||
autoApprove: true,
|
autoApprove: true,
|
||||||
title: "已按始终允许模式放行",
|
title: "已按始终允许模式放行",
|
||||||
});
|
});
|
||||||
|
expect(resolvePermissionApproval("always", "external_directory")).toMatchObject({
|
||||||
|
autoApprove: false,
|
||||||
|
autoReject: false,
|
||||||
|
});
|
||||||
|
expect(resolvePermissionApproval("always", "read")).toMatchObject({
|
||||||
|
autoApprove: false,
|
||||||
|
autoReject: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto approves sandboxed shell and file access in a conversation workspace", async () => {
|
it("auto approves sandboxed shell and file access in a conversation workspace", async () => {
|
||||||
@@ -142,12 +154,40 @@ describe("permission approval policy", () => {
|
|||||||
metadata: { command: "python3 analysis.py" },
|
metadata: { command: "python3 analysis.py" },
|
||||||
}),
|
}),
|
||||||
).toMatchObject({ autoApprove: true, autoReject: false });
|
).toMatchObject({ autoApprove: true, autoReject: false });
|
||||||
|
for (const approvalMode of ["auto", "always"] as const) {
|
||||||
|
expect(
|
||||||
|
resolvePermissionApproval(approvalMode, "bash", {
|
||||||
|
workspaceRoot,
|
||||||
|
metadata: { command: "cat .env" },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ autoApprove: false, autoReject: false });
|
||||||
|
}
|
||||||
expect(
|
expect(
|
||||||
canAutoApprovePermission("edit", {
|
canAutoApprovePermission("edit", {
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
metadata: { filePath: join(workspaceRoot, "result.json") },
|
metadata: { filePath: join(workspaceRoot, "result.json") },
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
resolvePermissionApproval("always", "edit", {
|
||||||
|
workspaceRoot,
|
||||||
|
metadata: { filePath: join(workspaceRoot, "result.json") },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ autoApprove: true, autoReject: false });
|
||||||
|
for (const filePath of [".env", "data/private.json", "logs/latest.log"]) {
|
||||||
|
expect(
|
||||||
|
resolvePermissionApproval("auto", "edit", {
|
||||||
|
workspaceRoot,
|
||||||
|
metadata: { filePath: join(workspaceRoot, filePath) },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ autoApprove: false, autoReject: false });
|
||||||
|
expect(
|
||||||
|
resolvePermissionApproval("always", "edit", {
|
||||||
|
workspaceRoot,
|
||||||
|
metadata: { filePath: join(workspaceRoot, filePath) },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ autoApprove: false, autoReject: false });
|
||||||
|
}
|
||||||
expect(
|
expect(
|
||||||
canAutoApprovePermission("glob", {
|
canAutoApprovePermission("glob", {
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
@@ -173,17 +213,19 @@ describe("permission approval policy", () => {
|
|||||||
"(rm -rf ./target)",
|
"(rm -rf ./target)",
|
||||||
"! rm -rf ./target",
|
"! rm -rf ./target",
|
||||||
"npm test && rm --recursive --force ./target",
|
"npm test && rm --recursive --force ./target",
|
||||||
])("rejects direct recursive force removal in always mode: %s", (command) => {
|
])("asks before recursive force removal in always mode: %s", (command) => {
|
||||||
expect(
|
for (const approvalMode of ["auto", "always"] as const) {
|
||||||
resolvePermissionApproval("always", "bash", {
|
expect(
|
||||||
metadata: { command },
|
resolvePermissionApproval(approvalMode, "bash", {
|
||||||
patterns: [command],
|
metadata: { command },
|
||||||
}),
|
patterns: [command],
|
||||||
).toMatchObject({
|
}),
|
||||||
autoApprove: false,
|
).toMatchObject({
|
||||||
autoReject: true,
|
autoApprove: false,
|
||||||
title: "已拒绝递归强制删除",
|
autoReject: false,
|
||||||
});
|
title: "等待权限确认",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["rm tmp.txt", "rm -f tmp.txt", "rm -r tmp-dir", "echo 'rm -rf tmp'"])(
|
it.each(["rm tmp.txt", "rm -f tmp.txt", "rm -r tmp-dir", "echo 'rm -rf tmp'"])(
|
||||||
|
|||||||
@@ -584,6 +584,28 @@ describe("streamPromptResponse", () => {
|
|||||||
always: ["npm test"],
|
always: ["npm test"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-auto-external",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
permission: "external_directory",
|
||||||
|
patterns: ["/app/opencode.json/*"],
|
||||||
|
metadata: { filepath: "/app/opencode.json" },
|
||||||
|
always: ["/app/opencode.json/*"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-auto-env",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
permission: "read",
|
||||||
|
patterns: [".env"],
|
||||||
|
metadata: { filepath: ".env" },
|
||||||
|
always: [".env"],
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
|
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
|
||||||
]),
|
]),
|
||||||
prompt: async () => undefined,
|
prompt: async () => undefined,
|
||||||
@@ -606,9 +628,19 @@ describe("streamPromptResponse", () => {
|
|||||||
request_id: "perm-auto-bash",
|
request_id: "perm-auto-bash",
|
||||||
permission: "bash",
|
permission: "bash",
|
||||||
});
|
});
|
||||||
|
expect(events.filter((item) => item.event === "permission_request")[1]?.data).toMatchObject({
|
||||||
|
request_id: "perm-auto-external",
|
||||||
|
permission: "external_directory",
|
||||||
|
target: "/app/opencode.json",
|
||||||
|
});
|
||||||
|
expect(events.filter((item) => item.event === "permission_request")[2]?.data).toMatchObject({
|
||||||
|
request_id: "perm-auto-env",
|
||||||
|
permission: "read",
|
||||||
|
target: ".env",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("approves every OpenCode ask once in always mode", async () => {
|
it("approves routine asks but forwards external paths in always mode", async () => {
|
||||||
const replies: Array<Record<string, unknown>> = [];
|
const replies: Array<Record<string, unknown>> = [];
|
||||||
const runtime = {
|
const runtime = {
|
||||||
subscribeEvents: async () =>
|
subscribeEvents: async () =>
|
||||||
@@ -624,6 +656,28 @@ describe("streamPromptResponse", () => {
|
|||||||
always: ["npm test"],
|
always: ["npm test"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-always-external",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
permission: "external_directory",
|
||||||
|
patterns: ["/app/private/*"],
|
||||||
|
metadata: { filepath: "/app/private/config.json" },
|
||||||
|
always: ["/app/private/*"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-always-env",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
permission: "read",
|
||||||
|
patterns: [".env"],
|
||||||
|
metadata: { filepath: ".env" },
|
||||||
|
always: [".env"],
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
|
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
|
||||||
]),
|
]),
|
||||||
prompt: async () => undefined,
|
prompt: async () => undefined,
|
||||||
@@ -650,10 +704,17 @@ describe("streamPromptResponse", () => {
|
|||||||
reply: "once",
|
reply: "once",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(events.some((item) => item.event === "permission_request")).toBe(false);
|
expect(events.find((item) => item.event === "permission_request")?.data).toMatchObject({
|
||||||
|
request_id: "perm-always-external",
|
||||||
|
permission: "external_directory",
|
||||||
|
});
|
||||||
|
expect(events.filter((item) => item.event === "permission_request")[1]?.data).toMatchObject({
|
||||||
|
request_id: "perm-always-env",
|
||||||
|
permission: "read",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects recursive force removal even in always mode", async () => {
|
it("asks before recursive force removal even in always mode", async () => {
|
||||||
const replies: Array<Record<string, unknown>> = [];
|
const replies: Array<Record<string, unknown>> = [];
|
||||||
const runtime = {
|
const runtime = {
|
||||||
subscribeEvents: async () =>
|
subscribeEvents: async () =>
|
||||||
@@ -687,19 +748,10 @@ describe("streamPromptResponse", () => {
|
|||||||
write: (event, data) => events.push({ event, data }),
|
write: (event, data) => events.push({ event, data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(replies).toEqual([
|
expect(replies).toEqual([]);
|
||||||
{
|
expect(events.find((item) => item.event === "permission_request")?.data).toMatchObject({
|
||||||
requestId: "perm-always-rm-rf",
|
|
||||||
sessionId: "runtime-session-1",
|
|
||||||
directory: "/tmp/conversation-workspace-1",
|
|
||||||
reply: "reject",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
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-always-rm-rf",
|
request_id: "perm-always-rm-rf",
|
||||||
reply: "reject",
|
permission: "bash",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -735,6 +787,7 @@ describe("streamPromptResponse", () => {
|
|||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
clientSessionId: "client-session-1",
|
clientSessionId: "client-session-1",
|
||||||
message: "read /tmp",
|
message: "read /tmp",
|
||||||
|
approvalMode: "auto",
|
||||||
write: (event, data) => events.push({ event, data }),
|
write: (event, data) => events.push({ event, data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("internal OpenCode permissions", () => {
|
|||||||
expect(dockerfile.startsWith("FROM smanx/opencode:1.18.13@")).toBe(true);
|
expect(dockerfile.startsWith("FROM smanx/opencode:1.18.13@")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps protected paths denied in every approval mode", async () => {
|
it("sends protected paths to approval while allowing mounted skills", async () => {
|
||||||
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
|
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
|
||||||
permission?: Record<string, string | Record<string, string>>;
|
permission?: Record<string, string | Record<string, string>>;
|
||||||
};
|
};
|
||||||
@@ -34,27 +34,35 @@ describe("internal OpenCode permissions", () => {
|
|||||||
const read = permission.read as Record<string, string> | undefined;
|
const read = permission.read as Record<string, string> | undefined;
|
||||||
|
|
||||||
expect(permission["*"]).toBe("ask");
|
expect(permission["*"]).toBe("ask");
|
||||||
expect(permission.external_directory).toBe("deny");
|
expect(Object.entries(permission.external_directory ?? {})).toEqual([
|
||||||
|
["*", "ask"],
|
||||||
|
["/app/.opencode/skills/*", "allow"],
|
||||||
|
]);
|
||||||
expect(permission.task).toBe("deny");
|
expect(permission.task).toBe("deny");
|
||||||
expect(permission.question).toBe("allow");
|
expect(permission.question).toBe("allow");
|
||||||
expect(permission.activity_update).toBe("allow");
|
expect(permission.activity_update).toBe("allow");
|
||||||
expect(permission.final_answer).toBe("allow");
|
expect(permission.final_answer).toBe("allow");
|
||||||
expect(permission.todowrite).toBe("allow");
|
expect(permission.todowrite).toBe("allow");
|
||||||
expect(read?.["*"]).toBe("allow");
|
expect(read?.["*"]).toBe("allow");
|
||||||
expect(read?.["data/**"]).toBe("deny");
|
expect(read?.[".env"]).toBe("ask");
|
||||||
expect(read?.["**/logs/**"]).toBe("deny");
|
expect(read?.["data/**"]).toBe("ask");
|
||||||
|
expect(read?.["**/logs/**"]).toBe("ask");
|
||||||
|
expect(Object.values(read ?? {})).not.toContain("deny");
|
||||||
expect(edit?.["*"]).toBe("ask");
|
expect(edit?.["*"]).toBe("ask");
|
||||||
expect(edit?.["data/**"]).toBe("deny");
|
expect(edit?.[".env"]).toBe("ask");
|
||||||
expect(edit?.["**/logs/**"]).toBe("deny");
|
expect(edit?.["data/**"]).toBe("ask");
|
||||||
|
expect(edit?.["**/logs/**"]).toBe("ask");
|
||||||
|
expect(Object.values(edit ?? {})).not.toContain("deny");
|
||||||
expect(bash?.["*"]).toBe("ask");
|
expect(bash?.["*"]).toBe("ask");
|
||||||
expect(bash?.["rm *"]).toBe("ask");
|
expect(bash?.["rm *"]).toBe("ask");
|
||||||
expect(bash?.["rm -rf *"]).toBe("deny");
|
expect(bash?.["rm -rf *"]).toBe("ask");
|
||||||
expect(bash?.["rm -fr *"]).toBe("deny");
|
expect(bash?.["rm -fr *"]).toBe("ask");
|
||||||
expect(bash?.["rm -r -f *"]).toBe("deny");
|
expect(bash?.["rm -r -f *"]).toBe("ask");
|
||||||
expect(bash?.["rm -f -r *"]).toBe("deny");
|
expect(bash?.["rm -f -r *"]).toBe("ask");
|
||||||
expect(bash?.["rm --recursive --force *"]).toBe("deny");
|
expect(bash?.["rm --recursive --force *"]).toBe("ask");
|
||||||
expect(bash?.["rm --force --recursive *"]).toBe("deny");
|
expect(bash?.["rm --force --recursive *"]).toBe("ask");
|
||||||
expect(bash?.["*.env*"]).toBe("deny");
|
expect(bash?.["*.env*"]).toBe("ask");
|
||||||
|
expect(Object.values(bash ?? {})).not.toContain("deny");
|
||||||
expect(bash?.["*data/*"]).toBeUndefined();
|
expect(bash?.["*data/*"]).toBeUndefined();
|
||||||
expect(bash?.["*logs/*"]).toBeUndefined();
|
expect(bash?.["*logs/*"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user