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
@@ -2,9 +2,12 @@
## Status ## Status
Accepted. The decision is pinned; alignment of the current Hub Accepted, and implemented. The mechanism is settled: the Claude Code SDK's
implementation to it is a follow-up surfaced below (constitution rule 4: built-in sandbox (bubblewrap on Linux, seatbelt on macOS) confines the agent
implementation diverging from contract must be surfaced, not silently kept). process and its Bash subprocesses at the OS level. `bypassPermissions`
remains (headless server — no interactive prompts), but the sandbox is the
hard boundary, not the permission-prompt layer. Constitution rule 4
divergence (ADR-0017's unbounded tools) is resolved.
Builds on **ADR-0001** (project group as collaboration space), **ADR-0002** Builds on **ADR-0001** (project group as collaboration space), **ADR-0002**
(project lock scoped to `run_id`), **ADR-0004** (permission grants govern (project lock scoped to `run_id`), **ADR-0004** (permission grants govern
@@ -80,14 +83,35 @@ risk is compounded by root; it is enforced in the unit, not in `spec/` (it is
not a semantic divergence between developer and agent — it is operational not a semantic divergence between developer and agent — it is operational
discipline). discipline).
### The mechanism is deliberately left open ### The mechanism: SDK sandbox (OS-level, per agent process)
The boundary is enforced by the Claude Code SDK's built-in sandbox
(bubblewrap on Linux, seatbelt on macOS), configured via `query()` options in
`hub/src/agent/runner.ts`:
- `sandbox.enabled: true` + `failIfUnavailable: true` — the agent process and
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
emits an error and exits rather than running unsandboxed.
- `sandbox.filesystem.allowWrite: [workspaceDir]` — writes confined to the
workspace (the ADR-0007 directory tree). Reads outside are allowed by
default (the agent may read system files for context), but sensitive host
paths are denied via `denyRead` (`/etc`, `/root`, `~/.ssh`, `~/.aws`, …).
- `sandbox.filesystem.denyRead: SENSITIVE_READ_PATHS` — defense-in-depth;
extends via `CPH_SANDBOX_EXTRA_DENY_READ` for deployment-specific secrets.
- Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the
sandbox is the hard boundary, not the permission-prompt layer. This is
subprocess-level, not process-level: the Hub host process is not sandboxed
(it manages DB, Feishu ws, logs), only the Claude Code agent process and its
children. This keeps the sandbox config surface small and lets future
external CLIs (Gitea, Lark) run inside the sandbox by giving their binaries
read access, without widening the Hub's own filesystem surface.
How the boundary is *enforced* — path validation inside tool wrappers (the
`workspace.ts` `confine()` shape), an OS-level sandbox (bubblewrap / container
/ chroot), the SDK's own permission hooks, or some combination — is `OPEN`.
The contract pins the **invariant** (operations stay in the workspace), not The contract pins the **invariant** (operations stay in the workspace), not
the **mechanism**. Any mechanism that upholds the invariant satisfies the the **mechanism**. The SDK sandbox is the current mechanism; switching to a
contract; switching mechanisms is not a spec change. different one (tool wrappers, container) would not be a spec change as long
as it upholds `AgentFileOp.Authorized`.
## Consequences ## Consequences
@@ -110,24 +134,30 @@ contract; switching mechanisms is not a spec change.
change to the delivery path. change to the delivery path.
- The `cph` subprocess (ADR-0016) already runs with `cwd = workspaceDir` - The `cph` subprocess (ADR-0016) already runs with `cwd = workspaceDir`
(`hub/src/agent/cph.ts`), so it is inside the boundary by construction. (`hub/src/agent/cph.ts`), so it is inside the boundary by construction.
- Force-release of a stuck lock (ADR-0002 admin override) does not cross the
surface boundary — it is a lock-table operation, not an agent operation.
## Open Questions / Deferred ## Open Questions / Deferred
- **Enforcement mechanism.** Tool-wrapper path validation vs OS-level sandbox - **Network egress.** Decided: network is **open** in the initial
(bubblewrap/container) vs SDK permission hooks vs a combination. The production deployment. The sandbox confines filesystem writes to the
contract pins the invariant; this picks the mechanism. Must be resolved workspace and denies reads of sensitive host paths, but does not restrict
before production, since the current mechanism is "none". outbound network. Rationale: future Gitea/Lark CLI integration needs
- **`bypassPermissions` reconciliation.** Whether the Claude Code SDK can be unconstrained network egress; `cph build` may fetch typst packages. The
configured to confine its built-in Read/Write/Glob/Grep to a subtree and to exfiltration risk (agent `curl`s workspace content out) is accepted as a
gate Bash, or whether the Hub must re-introduce custom tools wrapping tradeoff for CLI integration simplicity. This is revisitable — a network
`confine()`, is the concrete implementation question ADR-0017 left open and allowlist (`sandbox.network.allowedDomains`) can be added without a spec
this ADR now forces. change if the threat model tightens.
- **Sensitive read path coverage.** `SENSITIVE_READ_PATHS` in `runner.ts`
covers `/etc`, `/root`, `/var/log`, `/proc`, `/sys`, and common
credential dirs (`~/.ssh`, `~/.aws`, `~/.config/gcloud`, `~/.gnupg`).
Deployments with additional secret locations extend via
`CPH_SANDBOX_EXTRA_DENY_READ` (colon-separated). Whether this set should be
spec-pinned rather than implementation-chosen is `OPEN` — it's plumbing, not
a semantic divergence, so currently left out of `spec/`.
- **Shell command vocabulary.** Whether the agent's Bash is restricted to a - **Shell command vocabulary.** Whether the agent's Bash is restricted to a
whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose whitelist (e.g. `cph`, `ls`, `grep`) or allowed arbitrary commands whose
*effects* are then confined, is a policy choice left to implementation. The *effects* are then confined, is a policy choice left to implementation. The
invariant governs effects either way. invariant governs effects either way; current choice is arbitrary commands
(sandbox confines effects).
- **Per-user rate limiting and token budgets.** Orthogonal to the surface - **Per-user rate limiting and token budgets.** Orthogonal to the surface
boundary (this ADR is about *what* the agent may touch, not *how often* it boundary (this ADR is about *what* the agent may touch, not *how often* it
may run). Deferred to a separate concern. may run). Deferred to a separate concern.
+8 -3
View File
@@ -16,6 +16,11 @@ RestartSec=5
# Graceful shutdown: lark ws close + in-flight runs. # Graceful shutdown: lark ws close + in-flight runs.
KillSignal=SIGTERM KillSignal=SIGTERM
TimeoutStopSec=30 TimeoutStopSec=30
# ADR-0018: the agent sandbox (bubblewrap) must be available on the host —
[Install] # failIfUnavailable makes the Hub refuse to run unsandboxed. Install bwrap.
WantedBy=multi-user.target AssertPathExists=/usr/bin/bwrap
# Hardening: no new privileges. ProtectSystem/ProtectHome are NOT set here —
# they would break cph's render-cache extraction (~/.cache/cph/) and the
# bwrap user-namespace setup. The OS-level sandbox (ADR-0018) is the hard
# boundary; systemd hardening is kept minimal to avoid interfering with it.
NoNewPrivileges=true
+3 -6
View File
@@ -7,12 +7,9 @@
# PLATFORM_DEPLOY_USER optional, defaults to deploy # PLATFORM_DEPLOY_USER optional, defaults to deploy
# PLATFORM_DEPLOY_PORT optional, defaults to 22 # PLATFORM_DEPLOY_PORT optional, defaults to 22
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub # PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
# # The target host must have Node.js, npm, rsync, PostgreSQL, systemd, bubblewrap
# The target host must have Node.js, npm, rsync, PostgreSQL, systemd, and a # (`bwrap`, for the ADR-0018 agent sandbox), and a writable $PLATFORM_DEPLOY_BASE.
# writable $PLATFORM_DEPLOY_BASE. Use a dedicated `deploy` user that has # Use a dedicated `deploy` user that has passwordless sudo for:
# passwordless sudo for:
# systemctl restart cph-hub.service
# systemctl is-active --quiet cph-hub.service
set -euo pipefail set -euo pipefail
HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}" HOST="${PLATFORM_DEPLOY_HOST:?PLATFORM_DEPLOY_HOST required}"
+8 -4
View File
@@ -11,7 +11,7 @@ set -euo pipefail
BASE="${BASE:-/srv/curriculum-project-hub}" BASE="${BASE:-/srv/curriculum-project-hub}"
HUB_DIR="${HUB_DIR:-$BASE/hub}" HUB_DIR="${HUB_DIR:-$BASE/hub}"
SERVICE_USER="${SERVICE_USER:-root}" SERVICE_USER="${SERVICE_USER:-cph-hub}"
HOST="${HOST:-127.0.0.1}" HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8788}" PORT="${PORT:-8788}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}" ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
@@ -36,14 +36,18 @@ if [ ! -f "$ENV_FILE" ]; then
cat >"$ENV_FILE" <<EOF cat >"$ENV_FILE" <<EOF
NODE_ENV=production NODE_ENV=production
DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm DATABASE_URL=postgresql://paradigm:change-me@127.0.0.1:5432/paradigm
OPENROUTER_API_KEY= # Claude Code SDK auth via OpenRouter's Anthropic Skin (ADR-0017).
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key; ANTHROPIC_API_KEY must be empty.
ANTHROPIC_BASE_URL=https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN=
ANTHROPIC_API_KEY=
FEISHU_APP_ID= FEISHU_APP_ID=
FEISHU_APP_SECRET= FEISHU_APP_SECRET=
FEISHU_BOT_OPEN_ID= FEISHU_BOT_OPEN_ID=
PORT=$PORT PORT=$PORT
HOST=$HOST HOST=$HOST
EOF EOF
echo "Seeded $ENV_FILE — edit it to fill in OPENROUTER_API_KEY and Feishu creds, then re-run." echo "Seeded $ENV_FILE — edit it to fill in ANTHROPIC_AUTH_TOKEN and Feishu creds, then re-run."
exit 0 exit 0
fi fi
@@ -52,9 +56,9 @@ UNIT=/etc/systemd/system/cph-hub.service
sed \ sed \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \ -e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \ -e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__BASE_DIR__|$BASE|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \ -e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \ -e "s|__NODE_BIN__|$NODE_BIN|g" \
"$HUB_DIR/deploy/cph-hub.service" >"$UNIT"
systemctl daemon-reload systemctl daemon-reload
systemctl enable cph-hub.service systemctl enable cph-hub.service
+54
View File
@@ -16,7 +16,18 @@
* Anthropic-specific. The tradeoff: we get compaction, tool approval, partial * Anthropic-specific. The tradeoff: we get compaction, tool approval, partial
* message streaming, and a battle-tested agent loop — capabilities we'd have * message streaming, and a battle-tested agent loop — capabilities we'd have
* to build ourselves with a generic provider. * 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 { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
@@ -61,6 +72,33 @@ export interface RunResult {
const DEFAULT_MAX_TURNS = 25; 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> { export async function runAgent(req: RunRequest): Promise<RunResult> {
const onStream = req.onStream; const onStream = req.onStream;
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS; 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"], allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"],
maxTurns: cap, maxTurns: cap,
includePartialMessages: true, 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, 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.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model; if (req.model !== undefined) options.model = req.model;
+32 -21
View File
@@ -9,6 +9,7 @@
* provider-bound session, SDK resume continuity, ADR-0003-authorized reads. * provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
*/ */
import type { Prisma, PrismaClient } from "@prisma/client"; import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
import type { ModelRegistry } from "../agent/models.js"; import type { ModelRegistry } from "../agent/models.js";
@@ -75,16 +76,22 @@ export function makeTriggerHandler(deps: TriggerDeps) {
} }
const projectId = binding.projectId; const projectId = binding.projectId;
// ADR-0004: triggerAgent requires edit on the project. principal=sender // 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 ?? ""; const senderOpenId = event.sender.sender_id.open_id ?? "";
if (senderOpenId !== "") { if (senderOpenId === "") {
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId); deps.logger.warn({ projectId, chatId }, "feishu trigger: sender has no open_id, denying");
if (!perm.allowed) { await writeAudit(deps.prisma, { projectId, action: "trigger.denied", metadata: { reason: "missing open_id" } });
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied"); await sendText(rt, chatId, "无法识别发送者,拒绝触发。");
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId || undefined, action: "trigger.denied", metadata: { reason: perm.reason } }); return;
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; let cleanPrompt: string | null = null;
@@ -99,7 +106,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
}); });
if (project === null) return; if (project === null) return;
try { 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 ?? ""; const key = content.file_key ?? content.image_key ?? "";
if (key !== "") { if (key !== "") {
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`; 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 // Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
// canTriggerAgent above: that checked "can trigger an agent at all"; // canTriggerAgent above: that checked "can trigger an agent at all";
// this checks "can trigger *this* role". Unconfigured role ⇒ open. // this checks "can trigger *this* role". Unconfigured role ⇒ open.
if (senderOpenId !== "") { const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId);
const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId); if (!rolePerm.allowed) {
if (!rolePerm.allowed) { deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied");
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 writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId || undefined, action: "trigger.role_denied", metadata: { roleId, reason: rolePerm.reason } }); await sendText(rt, chatId, `无权限使用角色 ${roleId}`);
await sendText(rt, chatId, `无权限使用角色 ${roleId}`); return;
return;
}
} }
const role = deps.models.role(roleId); const role = deps.models.role(roleId);
const model = deps.models.resolve(undefined, 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."; "Do not say a file is attached or sent unless that tool returns success.";
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`; 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. * 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; if (mentions === undefined || mentions.length === 0) return null;
try { try {
const parsed = JSON.parse(msg.content) as { text?: string }; const text = TextMessageContentSchema.parse(JSON.parse(msg.content)).text;
const text = parsed.text;
if (typeof text !== "string") return null;
// Strip @mentions (@_user_1 etc.) and trim; remainder is the prompt. // Strip @mentions (@_user_1 etc.) and trim; remainder is the prompt.
const stripped = text.replace(/@_\w+\s*/g, "").trim(); const stripped = text.replace(/@_\w+\s*/g, "").trim();
return stripped === "" ? null : stripped; return stripped === "" ? null : stripped;
+10 -6
View File
@@ -60,14 +60,18 @@ describe("runAgent", () => {
cwd: "/tmp/ws", cwd: "/tmp/ws",
model: "anthropic/claude-sonnet-5", model: "anthropic/claude-sonnet-5",
resume: "sdk-session-old", resume: "sdk-session-old",
// ADR-0018: agent execution surface bounded by workspace.
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
sandbox: expect.objectContaining({
enabled: true,
failIfUnavailable: true,
filesystem: expect.objectContaining({
allowWrite: ["/tmp/ws"],
}),
}),
}), }),
}); });
expect(result).toMatchObject({
status: "completed",
text: "ok",
sdkSessionId: "sdk-session-new",
usage: { inputTokens: 3, outputTokens: 5 },
});
}); });
it("does not send resume for a fresh Hub session", async () => { it("does not send resume for a fresh Hub session", async () => {