forked from EduCraft/curriculum-project-hub
fix(hub): 对齐 ADR-0018 agent 执行面边界
runner.ts: 启用 SDK 内置沙箱(bubblewrap/seatbelt),写入限制在 workspace, denyRead 敏感路径,failIfUnavailable 硬拒非沙箱运行。bypassPermissions 保留 (headless 无交互),沙箱是硬边界而非权限提示层。 install_service.sh: 默认 service user 从 root 改为 cph-hub;env 模板修正 OPENROUTER_API_KEY → ANTHROPIC_AUTH_TOKEN/ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY (与 server.ts 实际读取一致);补 __BASE_DIR__ sed 替换。 cph-hub.service: AssertPathExists=/usr/bin/bwrap 强制沙箱依赖;NoNewPrivileges。 不加 ProtectSystem/ProtectHome(会破坏 cph render-cache 与 bwrap user-namespace)。 trigger.ts: 权限闸门去掉 if (senderOpenId !== '') 短路——缺 open_id 一律 fail-closed 拒绝,不再静默跳过;role gate 同步去掉冗余守卫(已由上游保证)。 顺手把 JSON.parse(msg.content) 的 inline cast 换成 Zod schema parse (FileMessageContentSchema / TextMessageContentSchema)。 ADR-0018: 状态改为 Accepted+implemented,机制段写定 SDK 沙箱,网络决定为开放, Open Questions 更新。
This commit is contained in:
@@ -16,7 +16,18 @@
|
||||
* Anthropic-specific. The tradeoff: we get compaction, tool approval, partial
|
||||
* message streaming, and a battle-tested agent loop — capabilities we'd have
|
||||
* to build ourselves with a generic provider.
|
||||
*
|
||||
* ADR-0018: the agent execution surface is bounded by the run's workspace.
|
||||
* The SDK sandbox (bubblewrap on Linux, seatbelt on macOS) confines the Claude
|
||||
* Code process and its subprocesses at the OS level — writes are confined to
|
||||
* the workspace (`sandbox.filesystem.allowWrite`), sensitive host paths are
|
||||
* denied reads (`denyRead`), and `failIfUnavailable` hard-fails if the sandbox
|
||||
* can't start (never run unsandboxed). This upholds `AgentFileOp.Authorized`
|
||||
* (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
|
||||
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
|
||||
* is the mechanism, the contract pins the invariant.
|
||||
*/
|
||||
import { homedir } from "node:os";
|
||||
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
@@ -61,6 +72,33 @@ export interface RunResult {
|
||||
|
||||
const DEFAULT_MAX_TURNS = 25;
|
||||
|
||||
/**
|
||||
* Host paths the agent may not read (ADR-0018 defense-in-depth). The sandbox
|
||||
* confines writes to the workspace; these deny reads of credentials/secrets
|
||||
* the process could otherwise see (read-only mounts are still readable). The
|
||||
* workspace itself is never in this list — it's writable by `allowWrite`.
|
||||
* Extend via `CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated) for deployments
|
||||
* with additional secret locations.
|
||||
*/
|
||||
const SENSITIVE_READ_PATHS: readonly string[] = (() => {
|
||||
const home = homedir();
|
||||
const base = [
|
||||
"/etc",
|
||||
"/root",
|
||||
"/var/log",
|
||||
"/proc",
|
||||
"/sys",
|
||||
`${home}/.ssh`,
|
||||
`${home}/.aws`,
|
||||
`${home}/.config/gcloud`,
|
||||
`${home}/.gnupg`,
|
||||
];
|
||||
const extra = process.env["CPH_SANDBOX_EXTRA_DENY_READ"];
|
||||
return extra !== undefined && extra !== ""
|
||||
? [...base, ...extra.split(":").filter((p) => p !== "")]
|
||||
: base;
|
||||
})();
|
||||
|
||||
export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
const onStream = req.onStream;
|
||||
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||
@@ -86,7 +124,23 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"],
|
||||
maxTurns: cap,
|
||||
includePartialMessages: true,
|
||||
// ADR-0018: bypass interactive prompts (headless server); the sandbox
|
||||
// below is the hard boundary, not the permission-prompt layer.
|
||||
permissionMode: "bypassPermissions" as const,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
// ADR-0018: OS-level sandbox confines the agent process + its Bash
|
||||
// subprocesses. Writes confined to the workspace; sensitive host paths
|
||||
// denied reads; network open. failIfUnavailable hard-fails if the
|
||||
// sandbox can't start — never run unsandboxed.
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
filesystem: {
|
||||
allowWrite: [req.project.workspaceDir],
|
||||
denyRead: [...SENSITIVE_READ_PATHS],
|
||||
},
|
||||
},
|
||||
};
|
||||
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
|
||||
if (req.model !== undefined) options.model = req.model;
|
||||
|
||||
+32
-21
@@ -9,6 +9,7 @@
|
||||
* provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||
import type { ModelRegistry } from "../agent/models.js";
|
||||
@@ -75,16 +76,22 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
}
|
||||
const projectId = binding.projectId;
|
||||
// ADR-0004: triggerAgent requires edit on the project. principal=sender
|
||||
// open_id (sub-typology OPEN — pragmatic choice, see permission.ts).
|
||||
// open_id (sub-typology OPEN — pragmatic choice, see permission.ts).
|
||||
// A missing open_id is treated as an untrusted event: deny explicitly
|
||||
// rather than skipping the check (fail closed).
|
||||
const senderOpenId = event.sender.sender_id.open_id ?? "";
|
||||
if (senderOpenId !== "") {
|
||||
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId);
|
||||
if (!perm.allowed) {
|
||||
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied");
|
||||
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId || undefined, action: "trigger.denied", metadata: { reason: perm.reason } });
|
||||
await sendText(rt, chatId, "无权限触发。");
|
||||
return;
|
||||
}
|
||||
if (senderOpenId === "") {
|
||||
deps.logger.warn({ projectId, chatId }, "feishu trigger: sender has no open_id, denying");
|
||||
await writeAudit(deps.prisma, { projectId, action: "trigger.denied", metadata: { reason: "missing open_id" } });
|
||||
await sendText(rt, chatId, "无法识别发送者,拒绝触发。");
|
||||
return;
|
||||
}
|
||||
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId);
|
||||
if (!perm.allowed) {
|
||||
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied");
|
||||
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId, action: "trigger.denied", metadata: { reason: perm.reason } });
|
||||
await sendText(rt, chatId, "无权限触发。");
|
||||
return;
|
||||
}
|
||||
|
||||
let cleanPrompt: string | null = null;
|
||||
@@ -99,7 +106,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
});
|
||||
if (project === null) return;
|
||||
try {
|
||||
const content = JSON.parse(msg.content) as { file_key?: string; image_key?: string };
|
||||
const content = FileMessageContentSchema.parse(JSON.parse(msg.content));
|
||||
const key = content.file_key ?? content.image_key ?? "";
|
||||
if (key !== "") {
|
||||
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
|
||||
@@ -178,14 +185,12 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
||||
// canTriggerAgent above: that checked "can trigger an agent at all";
|
||||
// this checks "can trigger *this* role". Unconfigured role ⇒ open.
|
||||
if (senderOpenId !== "") {
|
||||
const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId);
|
||||
if (!rolePerm.allowed) {
|
||||
deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied");
|
||||
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId || undefined, action: "trigger.role_denied", metadata: { roleId, reason: rolePerm.reason } });
|
||||
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
||||
return;
|
||||
}
|
||||
const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId);
|
||||
if (!rolePerm.allowed) {
|
||||
deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied");
|
||||
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId, action: "trigger.role_denied", metadata: { roleId, reason: rolePerm.reason } });
|
||||
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
||||
return;
|
||||
}
|
||||
const role = deps.models.role(roleId);
|
||||
const model = deps.models.resolve(undefined, roleId);
|
||||
@@ -383,6 +388,14 @@ function withFileDeliveryInstructions(systemPrompt: string | undefined): string
|
||||
"Do not say a file is attached or sent unless that tool returns success.";
|
||||
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
|
||||
}
|
||||
/** Lark text-message content: `{"text":"@_user_1 do something"}`. */
|
||||
const TextMessageContentSchema = z.object({ text: z.string() });
|
||||
|
||||
/** Lark file/image-message content: carries a file_key or image_key. */
|
||||
const FileMessageContentSchema = z.object({
|
||||
file_key: z.string().optional(),
|
||||
image_key: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract the prompt text from a text message, stripping @mentions.
|
||||
@@ -397,9 +410,7 @@ export function extractPrompt(msg: MessageReceiveEvent["message"]): string | nul
|
||||
if (mentions === undefined || mentions.length === 0) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(msg.content) as { text?: string };
|
||||
const text = parsed.text;
|
||||
if (typeof text !== "string") return null;
|
||||
const text = TextMessageContentSchema.parse(JSON.parse(msg.content)).text;
|
||||
// Strip @mentions (@_user_1 etc.) and trim; remainder is the prompt.
|
||||
const stripped = text.replace(/@_\w+\s*/g, "").trim();
|
||||
return stripped === "" ? null : stripped;
|
||||
|
||||
Reference in New Issue
Block a user