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:
2026-07-08 13:11:14 +08:00
parent 5b7fd70124
commit ecbc2c8666
7 changed files with 166 additions and 61 deletions
+54
View File
@@ -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;